Files
superset2/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts
Amin Ghadersohi 12e1917957 fix(plugin-chart-echarts): prevent trendline stroke clipping at chart edges
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>
2026-02-12 15:18:09 +00:00

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('£');
});