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..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 @@ -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 unknown as ControlPanelState; const result = xAxisForceCategoricalControl.config.initialValue!( control, - state as any, + state, ); // Verify: should return control value (false) for non-numeric columns @@ -55,3 +59,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: {} }, + } as unknown as ControlStateMapping; + + const visible = xAxisForceCategoricalControl.config.visibility!({ + controls, + }); + + 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