From 66158aa477206fd16f5ee8fb94a136d2bddcbabd Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 18 Jun 2026 22:24:31 -0700 Subject: [PATCH 1/3] fix(echarts): allow forcing categorical x-axis for temporal columns The "Force categorical" toggle (xAxisForceCategoricalControl) was only visible when the x-axis column was Numeric. Temporal x-axes default to a continuous time scale, where ECharts auto-places ticks at "nice" intervals that don't line up with the actual buckets, so weekly/monthly grain markers appear shifted away from their ticks (issue #28204). The transform layer already supports a categorical axis for temporal data; only the control's visibility gate blocked it. Expose the toggle for temporal columns too so users can opt into a discrete, tick-aligned axis. The numeric-only auto-force-when-sorted behavior in initialValue is left unchanged, so existing temporal charts keep their time scale unless the user opts in. Co-Authored-By: Claude Opus 4.8 --- .../src/shared-controls/customControls.tsx | 7 +++- .../shared-controls/customControls.test.tsx | 24 +++++++++++ .../test/Timeseries/transformProps.test.ts | 41 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx index fd355ff11673..3604b0993593 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx @@ -238,11 +238,16 @@ export const xAxisForceCategoricalControl = { return state?.form_data?.x_axis_sort !== undefined || control.value; }, renderTrigger: true, + // Expose the toggle for numeric and temporal x-axes. Temporal columns + // default to a continuous time scale, where ECharts places ticks at "nice" + // intervals that don't align with the actual buckets (e.g. weekly grain + // markers landing between month ticks). Treating the axis as categorical + // lets each bucket map to a discrete, tick-aligned category. visibility: ({ controls }: { controls: ControlStateMapping }) => checkColumnType( getColumnLabel(controls?.x_axis?.value as QueryFormColumn), controls?.datasource?.datasource, - [GenericDataType.Numeric], + [GenericDataType.Numeric, GenericDataType.Temporal], ), shouldMapStateToProps: () => true, }, diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx index 38681840a773..8af72e5e9b0a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx @@ -55,3 +55,27 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori mockCheckColumnType.mockClear(); }); + +test('xAxisForceCategoricalControl is visible for numeric and temporal x-axes', () => { + const mockCheckColumnType = jest.mocked(checkColumnType); + mockCheckColumnType.mockReturnValue(true); + + const controls = { + x_axis: { value: 'date_column' }, + datasource: { datasource: {} }, + }; + + const visible = xAxisForceCategoricalControl.config.visibility!({ + controls, + } as any); + + expect(visible).toBe(true); + // Temporal columns must be included so the toggle is exposed for time-grain + // charts (e.g. weekly grain), where the time scale misaligns ticks/markers. + expect(mockCheckColumnType).toHaveBeenCalledWith('date_column', {}, [ + GenericDataType.Numeric, + GenericDataType.Temporal, + ]); + + mockCheckColumnType.mockClear(); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index e3b8724951cb..351d14652979 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -1569,6 +1569,47 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype', expect(xAxis.type).toBe(AxisType.Category); }); +test('temporal x coltype forced categorical yields a Category axis with date labels', () => { + // Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time + // scale places ticks at "nice" intervals that don't line up with the buckets. + // Forcing categorical maps each bucket to a discrete, tick-aligned category + // while still formatting the labels as dates rather than raw timestamps. + const ts1 = 1745784000000; + const ts2 = 1745870400000; + const chartProps = createTestChartProps({ + formData: { + metrics: ['metric'], + granularity_sqla: 'ds', + x_axis: '__timestamp', + xAxisForceCategorical: true, + }, + queriesData: [ + createTestQueryData( + [ + { __timestamp: ts1, metric: 10 }, + { __timestamp: ts2, metric: 20 }, + ], + { + colnames: ['__timestamp', 'metric'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ), + ], + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as { + type: string; + axisLabel: { formatter: (v: Date) => string }; + }; + + expect(xAxis.type).toBe(AxisType.Category); + const label = xAxis.axisLabel.formatter(new Date(ts1)); + expect(typeof label).toBe('string'); + expect(label).not.toMatch(/NaN/); + expect(label).not.toBe(String(ts1)); +}); + test('temporal x coltype wires the time formatter and Time axis', () => { // Regression guard: the happy path for time-series charts. Ensures that // Temporal coltype keeps routing through the TimeFormatter so a refactor From 66ccb94c2919022862b1ed3830c422bbe27db989 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 22:57:27 -0700 Subject: [PATCH 2/3] test: drop `as any` casts in customControls visibility/initialValue tests Use ControlPanelState / ControlStateMapping types instead of `any` to satisfy the no-any custom rule. Co-Authored-By: Claude Opus 4.8 --- .../test/shared-controls/customControls.test.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx index 8af72e5e9b0a..d0bcaa0ba003 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx @@ -20,7 +20,11 @@ import { GenericDataType } from '@apache-superset/core/common'; import { xAxisForceCategoricalControl } from '../../src/shared-controls/customControls'; import { checkColumnType } from '../../src/utils/checkColumnType'; -import type { ControlState } from '@superset-ui/chart-controls'; +import type { + ControlPanelState, + ControlState, + ControlStateMapping, +} from '@superset-ui/chart-controls'; jest.mock('../../src/utils/checkColumnType'); jest.mock('@superset-ui/core', () => ({ @@ -39,12 +43,12 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori controls: { x_axis: { value: 'date_column' }, datasource: { datasource: {} }, - }, - }; + } as unknown as ControlStateMapping, + } as ControlPanelState; const result = xAxisForceCategoricalControl.config.initialValue!( control, - state as any, + state, ); // Verify: should return control value (false) for non-numeric columns @@ -63,11 +67,11 @@ test('xAxisForceCategoricalControl is visible for numeric and temporal x-axes', const controls = { x_axis: { value: 'date_column' }, datasource: { datasource: {} }, - }; + } as unknown as ControlStateMapping; const visible = xAxisForceCategoricalControl.config.visibility!({ controls, - } as any); + }); expect(visible).toBe(true); // Temporal columns must be included so the toggle is exposed for time-grain From 28b19eb54db1d455c8fa8e4d2984d835dea9292e Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 23:56:28 -0700 Subject: [PATCH 3/3] fix(tests): cast through unknown for ControlPanelState in customControls test The single `as ControlPanelState` cast failed tsc because the partial state object does not sufficiently overlap with the full type. Mirror the visibility test and cast through `unknown`. Co-Authored-By: Claude Opus 4.8 --- .../test/shared-controls/customControls.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx index d0bcaa0ba003..029b19dd6d6a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx @@ -44,7 +44,7 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori x_axis: { value: 'date_column' }, datasource: { datasource: {} }, } as unknown as ControlStateMapping, - } as ControlPanelState; + } as unknown as ControlPanelState; const result = xAxisForceCategoricalControl.config.initialValue!( control,