mirror of
https://github.com/apache/superset.git
synced 2026-05-11 02:45:46 +00:00
The BigNumber with Trendline chart was clipping the line stroke at the edges (especially X=0) because the ECharts grid had zero padding when axes were hidden. The stroke extends beyond the data point by half its width, so pixels at the boundary were truncated by the viewport. Add half-strokeWidth padding to the grid on all sides when axes are hidden, and explicitly set lineStyle.width so the padding stays in sync with the actual stroke size. Fixes #33454 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
558 lines
15 KiB
TypeScript
558 lines
15 KiB
TypeScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
import { DatasourceType, TimeGranularity, VizType } from '@superset-ui/core';
|
|
import { supersetTheme } from '@apache-superset/core/ui';
|
|
import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps';
|
|
import {
|
|
BigNumberDatum,
|
|
BigNumberWithTrendlineChartProps,
|
|
BigNumberWithTrendlineFormData,
|
|
} from '../../src/BigNumber/types';
|
|
import { TIMESERIES_CONSTANTS } from '../../src/constants';
|
|
|
|
const formData = {
|
|
metric: 'value',
|
|
colorPicker: {
|
|
r: 0,
|
|
g: 122,
|
|
b: 135,
|
|
a: 1,
|
|
},
|
|
compareLag: 1,
|
|
xAxis: '__timestamp',
|
|
timeGrainSqla: TimeGranularity.QUARTER,
|
|
granularitySqla: 'ds',
|
|
compareSuffix: 'over last quarter',
|
|
viz_type: VizType.BigNumber,
|
|
yAxisFormat: '.3s',
|
|
datasource: 'test_datasource',
|
|
};
|
|
|
|
const rawFormData: BigNumberWithTrendlineFormData = {
|
|
colorPicker: { b: 0, g: 0, r: 0 },
|
|
datasource: '1__table',
|
|
metric: 'value',
|
|
color_picker: {
|
|
r: 0,
|
|
g: 122,
|
|
b: 135,
|
|
a: 1,
|
|
},
|
|
compare_lag: 1,
|
|
x_axis: '__timestamp',
|
|
time_grain_sqla: TimeGranularity.QUARTER,
|
|
granularity_sqla: 'ds',
|
|
compare_suffix: 'over last quarter',
|
|
viz_type: VizType.BigNumber,
|
|
y_axis_format: '.3s',
|
|
xAxis: '__timestamp',
|
|
};
|
|
|
|
function generateProps(
|
|
data: BigNumberDatum[],
|
|
extraFormData = {},
|
|
extraQueryData: any = {},
|
|
): BigNumberWithTrendlineChartProps {
|
|
return {
|
|
width: 200,
|
|
height: 500,
|
|
annotationData: {},
|
|
datasource: {
|
|
id: 0,
|
|
name: '',
|
|
type: DatasourceType.Table,
|
|
columns: [],
|
|
metrics: [],
|
|
columnFormats: {},
|
|
verboseMap: {},
|
|
},
|
|
rawDatasource: {},
|
|
rawFormData,
|
|
hooks: {},
|
|
initialValues: {},
|
|
formData: {
|
|
...formData,
|
|
...extraFormData,
|
|
},
|
|
queriesData: [
|
|
{
|
|
data,
|
|
...extraQueryData,
|
|
},
|
|
],
|
|
ownState: {},
|
|
filterState: {},
|
|
behaviors: [],
|
|
theme: supersetTheme,
|
|
};
|
|
}
|
|
|
|
describe('BigNumberWithTrendline', () => {
|
|
const props = generateProps(
|
|
[
|
|
{
|
|
__timestamp: 0,
|
|
value: 1.2345,
|
|
},
|
|
{
|
|
__timestamp: 100,
|
|
value: null,
|
|
},
|
|
],
|
|
{ showTrendLine: true },
|
|
);
|
|
|
|
describe('transformProps()', () => {
|
|
test('should fallback and format time', () => {
|
|
const transformed = transformProps(props);
|
|
// the first item is the last item sorted by __timestamp
|
|
const lastDatum = transformed.trendLineData?.pop();
|
|
|
|
// should use last available value
|
|
expect(lastDatum?.[0]).toStrictEqual(100);
|
|
expect(lastDatum?.[1]).toBeNull();
|
|
|
|
// should get the last non-null value
|
|
expect(transformed.bigNumber).toStrictEqual(1.2345);
|
|
// bigNumberFallback is only set when bigNumber is null after aggregation
|
|
expect(transformed.bigNumberFallback).toBeNull();
|
|
|
|
// should successfully formatTime by granularity
|
|
// @ts-expect-error
|
|
expect(transformed.formatTime(new Date('2020-01-01'))).toStrictEqual(
|
|
'2020-01-01 00:00:00',
|
|
);
|
|
});
|
|
|
|
test('should respect datasource d3 format', () => {
|
|
const propsWithDatasource = {
|
|
...props,
|
|
datasource: {
|
|
...props.datasource,
|
|
metrics: [
|
|
{
|
|
label: 'value',
|
|
metric_name: 'value',
|
|
d3format: '.2f',
|
|
uuid: '1',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const transformed = transformProps(propsWithDatasource);
|
|
// @ts-expect-error
|
|
expect(transformed.headerFormatter(transformed.bigNumber)).toStrictEqual(
|
|
'1.23',
|
|
);
|
|
});
|
|
|
|
test('should format with datasource currency', () => {
|
|
const propsWithDatasource = {
|
|
...props,
|
|
datasource: {
|
|
...props.datasource,
|
|
currencyFormats: {
|
|
value: { symbol: 'USD', symbolPosition: 'prefix' },
|
|
},
|
|
metrics: [
|
|
{
|
|
label: 'value',
|
|
metric_name: 'value',
|
|
d3format: '.2f',
|
|
currency: { symbol: 'USD', symbolPosition: 'prefix' },
|
|
uuid: '1',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const transformed = transformProps(propsWithDatasource);
|
|
// @ts-expect-error
|
|
expect(transformed.headerFormatter(transformed.bigNumber)).toStrictEqual(
|
|
'$ 1.23',
|
|
);
|
|
});
|
|
|
|
test('should show X axis when showXAxis is true', () => {
|
|
const transformed = transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxis: true,
|
|
},
|
|
});
|
|
expect((transformed.echartOptions?.xAxis as any).show).toBe(true);
|
|
});
|
|
|
|
test('should not show X axis when showXAxis is false', () => {
|
|
const transformed = transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxis: false,
|
|
},
|
|
});
|
|
expect((transformed.echartOptions?.xAxis as any).show).toBe(false);
|
|
});
|
|
|
|
test('should show Y axis when showYAxis is true', () => {
|
|
const transformed = transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showYAxis: true,
|
|
},
|
|
});
|
|
expect((transformed.echartOptions?.yAxis as any).show).toBe(true);
|
|
});
|
|
|
|
test('should not show Y axis when showYAxis is false', () => {
|
|
const transformed = transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showYAxis: false,
|
|
},
|
|
});
|
|
expect((transformed.echartOptions?.yAxis as any).show).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('should respect min/max label visibility settings', () => {
|
|
const transformed = transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxisMinMaxLabels: false,
|
|
showYAxisMinMaxLabels: true,
|
|
},
|
|
});
|
|
const xAxis = transformed.echartOptions?.xAxis as any;
|
|
const yAxis = transformed.echartOptions?.yAxis as any;
|
|
|
|
expect(xAxis.axisLabel.showMinLabel).toBe(false);
|
|
expect(xAxis.axisLabel.showMaxLabel).toBe(false);
|
|
expect(yAxis.axisLabel.showMinLabel).toBe(true);
|
|
expect(yAxis.axisLabel.showMaxLabel).toBe(true);
|
|
});
|
|
|
|
test('should use minimal grid when both axes are hidden', () => {
|
|
const transformed = transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxis: false,
|
|
showYAxis: false,
|
|
},
|
|
});
|
|
|
|
const series = (transformed.echartOptions?.series as any)?.[0];
|
|
expect(series?.lineStyle?.width).toBe(2);
|
|
|
|
const expectedPad = series.lineStyle.width / 2;
|
|
expect(transformed.echartOptions?.grid).toEqual({
|
|
bottom: expectedPad,
|
|
left: expectedPad,
|
|
right: expectedPad,
|
|
top: expectedPad,
|
|
});
|
|
});
|
|
|
|
test('should use expanded grid when either axis is shown', () => {
|
|
const expandedGrid = {
|
|
containLabel: true,
|
|
bottom: TIMESERIES_CONSTANTS.gridOffsetBottom,
|
|
left: TIMESERIES_CONSTANTS.gridOffsetLeft,
|
|
right: TIMESERIES_CONSTANTS.gridOffsetRight,
|
|
top: TIMESERIES_CONSTANTS.gridOffsetTop,
|
|
};
|
|
|
|
expect(
|
|
transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxis: true,
|
|
showYAxis: false,
|
|
},
|
|
}).echartOptions?.grid,
|
|
).toEqual(expandedGrid);
|
|
expect(
|
|
transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxis: false,
|
|
showYAxis: true,
|
|
},
|
|
}).echartOptions?.grid,
|
|
).toEqual(expandedGrid);
|
|
expect(
|
|
transformProps({
|
|
...props,
|
|
formData: {
|
|
...props.formData,
|
|
showXAxis: true,
|
|
showYAxis: true,
|
|
},
|
|
}).echartOptions?.grid,
|
|
).toEqual(expandedGrid);
|
|
});
|
|
});
|
|
|
|
describe('BigNumberWithTrendline - Aggregation Tests', () => {
|
|
const baseProps = {
|
|
width: 800,
|
|
height: 600,
|
|
formData: {
|
|
colorPicker: { r: 0, g: 0, b: 0, a: 1 },
|
|
metric: 'metric',
|
|
aggregation: 'LAST_VALUE',
|
|
},
|
|
queriesData: [
|
|
{
|
|
data: [
|
|
{ __timestamp: 1607558400000, metric: 10 },
|
|
{ __timestamp: 1607558500000, metric: 30 },
|
|
{ __timestamp: 1607558600000, metric: 50 },
|
|
{ __timestamp: 1607558700000, metric: 60 },
|
|
],
|
|
colnames: ['__timestamp', 'metric'],
|
|
coltypes: ['TIMESTAMP', 'BIGINT'],
|
|
},
|
|
],
|
|
hooks: {},
|
|
filterState: {},
|
|
datasource: {
|
|
columnFormats: {},
|
|
currencyFormats: {},
|
|
},
|
|
rawDatasource: {},
|
|
rawFormData: {},
|
|
theme: {
|
|
colors: {
|
|
grayscale: {
|
|
light5: '#fafafa',
|
|
},
|
|
},
|
|
},
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const propsWithEvenData = {
|
|
...baseProps,
|
|
queriesData: [
|
|
{
|
|
data: [
|
|
{ __timestamp: 1607558400000, metric: 10 },
|
|
{ __timestamp: 1607558500000, metric: 20 },
|
|
{ __timestamp: 1607558600000, metric: 30 },
|
|
{ __timestamp: 1607558700000, metric: 40 },
|
|
],
|
|
colnames: ['__timestamp', 'metric'],
|
|
coltypes: ['TIMESTAMP', 'BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
test('should correctly calculate SUM', () => {
|
|
const props = {
|
|
...baseProps,
|
|
formData: { ...baseProps.formData, aggregation: 'sum' },
|
|
queriesData: [
|
|
baseProps.queriesData[0],
|
|
{
|
|
data: [{ metric: 150 }],
|
|
colnames: ['metric'],
|
|
coltypes: ['BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const transformed = transformProps(props);
|
|
expect(transformed.bigNumber).toStrictEqual(150);
|
|
});
|
|
|
|
test('should correctly calculate AVG', () => {
|
|
const props = {
|
|
...baseProps,
|
|
formData: { ...baseProps.formData, aggregation: 'mean' },
|
|
queriesData: [
|
|
baseProps.queriesData[0],
|
|
{
|
|
data: [{ metric: 37.5 }],
|
|
colnames: ['metric'],
|
|
coltypes: ['BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const transformed = transformProps(props);
|
|
expect(transformed.bigNumber).toStrictEqual(37.5);
|
|
});
|
|
|
|
test('should correctly calculate MIN', () => {
|
|
const props = {
|
|
...baseProps,
|
|
formData: { ...baseProps.formData, aggregation: 'min' },
|
|
queriesData: [
|
|
baseProps.queriesData[0],
|
|
{
|
|
data: [{ metric: 10 }],
|
|
colnames: ['metric'],
|
|
coltypes: ['BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const transformed = transformProps(props);
|
|
expect(transformed.bigNumber).toStrictEqual(10);
|
|
});
|
|
|
|
test('should correctly calculate MAX', () => {
|
|
const props = {
|
|
...baseProps,
|
|
formData: { ...baseProps.formData, aggregation: 'max' },
|
|
queriesData: [
|
|
baseProps.queriesData[0],
|
|
{
|
|
data: [{ metric: 60 }],
|
|
colnames: ['metric'],
|
|
coltypes: ['BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const transformed = transformProps(props);
|
|
expect(transformed.bigNumber).toStrictEqual(60);
|
|
});
|
|
|
|
test('should correctly calculate MEDIAN (odd count)', () => {
|
|
const oddCountProps = {
|
|
...baseProps,
|
|
queriesData: [
|
|
{
|
|
data: [
|
|
{ __timestamp: 1607558300000, metric: 10 },
|
|
{ __timestamp: 1607558400000, metric: 20 },
|
|
{ __timestamp: 1607558500000, metric: 30 },
|
|
{ __timestamp: 1607558600000, metric: 40 },
|
|
{ __timestamp: 1607558700000, metric: 50 },
|
|
],
|
|
colnames: ['__timestamp', 'metric'],
|
|
coltypes: ['TIMESTAMP', 'BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const props = {
|
|
...oddCountProps,
|
|
formData: { ...oddCountProps.formData, aggregation: 'median' },
|
|
queriesData: [
|
|
oddCountProps.queriesData[0],
|
|
{
|
|
data: [{ metric: 30 }],
|
|
colnames: ['metric'],
|
|
coltypes: ['BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const transformed = transformProps(props);
|
|
expect(transformed.bigNumber).toStrictEqual(30);
|
|
});
|
|
|
|
test('should correctly calculate MEDIAN (even count)', () => {
|
|
const props = {
|
|
...propsWithEvenData,
|
|
formData: { ...propsWithEvenData.formData, aggregation: 'median' },
|
|
queriesData: [
|
|
propsWithEvenData.queriesData[0],
|
|
{
|
|
data: [{ metric: 25 }],
|
|
colnames: ['metric'],
|
|
coltypes: ['BIGINT'],
|
|
},
|
|
],
|
|
} as unknown as BigNumberWithTrendlineChartProps;
|
|
|
|
const transformed = transformProps(props);
|
|
expect(transformed.bigNumber).toStrictEqual(25);
|
|
});
|
|
|
|
test('should return the LAST_VALUE correctly', () => {
|
|
const transformed = transformProps(baseProps);
|
|
expect(transformed.bigNumber).toStrictEqual(10);
|
|
});
|
|
});
|
|
|
|
test('BigNumberWithTrendline AUTO mode should detect single currency', () => {
|
|
const props = generateProps(
|
|
[
|
|
{ __timestamp: 1607558400000, value: 1000, currency_code: 'USD' },
|
|
{ __timestamp: 1607558500000, value: 2000, currency_code: 'USD' },
|
|
],
|
|
{
|
|
yAxisFormat: ',.2f',
|
|
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
|
},
|
|
);
|
|
props.datasource.currencyCodeColumn = 'currency_code';
|
|
|
|
const transformed = transformProps(props);
|
|
// The headerFormatter should include $ for USD
|
|
expect(transformed.headerFormatter(1000)).toContain('$');
|
|
});
|
|
|
|
test('BigNumberWithTrendline AUTO mode should use neutral formatting for mixed currencies', () => {
|
|
const props = generateProps(
|
|
[
|
|
{ __timestamp: 1607558400000, value: 1000, currency_code: 'USD' },
|
|
{ __timestamp: 1607558500000, value: 2000, currency_code: 'EUR' },
|
|
],
|
|
{
|
|
yAxisFormat: ',.2f',
|
|
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
|
},
|
|
);
|
|
props.datasource.currencyCodeColumn = 'currency_code';
|
|
|
|
const transformed = transformProps(props);
|
|
// With mixed currencies, should not show currency symbol
|
|
const formatted = transformed.headerFormatter(1000);
|
|
expect(formatted).not.toContain('$');
|
|
expect(formatted).not.toContain('€');
|
|
});
|
|
|
|
test('BigNumberWithTrendline should preserve static currency format', () => {
|
|
const props = generateProps(
|
|
[
|
|
{ __timestamp: 1607558400000, value: 1000, currency_code: 'USD' },
|
|
{ __timestamp: 1607558500000, value: 2000, currency_code: 'EUR' },
|
|
],
|
|
{
|
|
yAxisFormat: ',.2f',
|
|
currencyFormat: { symbol: 'GBP', symbolPosition: 'prefix' },
|
|
},
|
|
);
|
|
props.datasource.currencyCodeColumn = 'currency_code';
|
|
|
|
const transformed = transformProps(props);
|
|
// Static mode should always show £
|
|
expect(transformed.headerFormatter(1000)).toContain('£');
|
|
});
|