Compare commits

...

3 Commits

Author SHA1 Message Date
Evan
28b19eb54d 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 <noreply@anthropic.com>
2026-06-18 23:56:28 -07:00
Evan
66ccb94c29 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 <noreply@anthropic.com>
2026-06-18 22:57:27 -07:00
Claude Code
66158aa477 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 <noreply@anthropic.com>
2026-06-18 22:24:31 -07:00
3 changed files with 79 additions and 5 deletions

View File

@@ -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,
},

View File

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

View File

@@ -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