This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 8849542bda1 test(explore): cover viz switch and control dependency
logic (#44842)
8849542bda1 is described below
commit 8849542bda142c04b78a24c1bbfec240459012ad
Author: Joe Li <[email protected]>
AuthorDate: Fri Oct 2 10:25:24 2026 -0700
test(explore): cover viz switch and control dependency logic (#44842)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../components/ControlPanelsContainer.test.tsx | 203 +++++++++++++++++++-
.../controlUtils/getSectionsToRender.test.ts | 179 ++++++++++++++++++
.../src/explore/reducers/exploreReducer.test.ts | 205 ++++++++++++++++++++-
3 files changed, 584 insertions(+), 3 deletions(-)
diff --git
a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx
b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx
index b295df43db1..623bf4e9b9c 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx
@@ -16,24 +16,34 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
import {
+ act,
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
import { t } from '@apache-superset/core/translation';
+import { getStandardizedControls } from '@superset-ui/chart-controls';
import {
ComparisonType,
DatasourceType,
FeatureFlag,
getChartControlPanelRegistry,
isFeatureEnabled,
+ QueryFormData,
QueryMode,
} from '@superset-ui/core';
-import { defaultControls, defaultState } from 'src/explore/store';
+import {
+ defaultControls,
+ defaultState,
+ getControlsState,
+} from 'src/explore/store';
import { ExplorePageState } from 'src/explore/types';
+import { setControlValue } from 'src/explore/actions/exploreActions';
import { getFormDataFromControls } from 'src/explore/controlUtils';
import {
ControlPanelsContainer,
@@ -55,6 +65,43 @@ const FormDataMock = () => {
return <div
data-test="mock-formdata">{Object.keys(formData).join(':')}</div>;
};
+// Mirrors the props ExploreViewContainer derives from the store, so the real
+// reducer and the real container run against each other.
+const StoreBackedControlPanelsContainer = () => {
+ const dispatch = useDispatch();
+ const exploreState = useSelector((state: ExplorePageState) => state.explore);
+ const actions = {
+ setControlValue: (
+ ...args: Parameters<typeof setControlValue>
+ ): ReturnType<typeof setControlValue> =>
+ dispatch(setControlValue(...args)) as unknown as ReturnType<
+ typeof setControlValue
+ >,
+ resetSemanticSelections: jest.fn(),
+ };
+ return (
+ <ControlPanelsContainer
+ exploreState={exploreState}
+ actions={actions}
+ datasource_type={DatasourceType.Table}
+ controls={exploreState.controls}
+ form_data={getFormDataFromControls(exploreState.controls)}
+ isDatasourceMetaLoading={false}
+ chart={
+ {
+ queriesResponse: null,
+ chartStatus: 'success',
+ } as ControlPanelsContainerProps['chart']
+ }
+ onQuery={jest.fn()}
+ onStop={jest.fn()}
+ canStopQuery={false}
+ chartIsStale={false}
+ errorMessage={null}
+ />
+ );
+};
+
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
describe('ControlPanelsContainer', () => {
const defaultTableConfig = {
@@ -99,8 +146,46 @@ describe('ControlPanelsContainer', () => {
],
};
+ // Reuses the 'Query' section label of the table config so the section stays
+ // expanded after a switch (the accordion only honors its initial open keys).
+ const switchTargetConfig = {
+ controlPanelSections: [
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [['groupby'], ['metrics'], ['limit']],
+ },
+ ],
+ };
+
+ // Consumes the standardized metrics/columns under field names the source viz
+ // never had, so values can only arrive through the StandardizedFormData
+ // transform and not by copying same-named form_data keys.
+ const switchMappedConfig = {
+ controlPanelSections: [
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [['size'], ['series_columns']],
+ },
+ ],
+ formDataOverrides: (formData: QueryFormData) => ({
+ ...formData,
+ size: getStandardizedControls().shiftMetric(),
+ series_columns: getStandardizedControls().popAllColumns(),
+ }),
+ };
+
beforeEach(() => {
getChartControlPanelRegistry().registerValue('table', defaultTableConfig);
+ getChartControlPanelRegistry().registerValue(
+ 'switch-mapped',
+ switchMappedConfig,
+ );
+ getChartControlPanelRegistry().registerValue(
+ 'switch-target',
+ switchTargetConfig,
+ );
jest.clearAllMocks();
// Default: feature disabled
mockIsFeatureEnabled.mockReturnValue(false);
@@ -108,6 +193,8 @@ describe('ControlPanelsContainer', () => {
afterEach(() => {
getChartControlPanelRegistry().remove('table');
+ getChartControlPanelRegistry().remove('switch-target');
+ getChartControlPanelRegistry().remove('switch-mapped');
jest.clearAllMocks();
});
@@ -686,6 +773,118 @@ describe('ControlPanelsContainer', () => {
getChartControlPanelRegistry().remove('pie');
});
+ function createStoreBackedExplore() {
+ const datasource = {
+ id: 1,
+ type: DatasourceType.Table,
+ columns: [{ column_name: 'name' }, { column_name: 'gender' }],
+ metrics: [{ metric_name: 'count' }],
+ verbose_map: {},
+ column_formats: {},
+ };
+ const baseState = { datasource };
+ const formData = {
+ viz_type: 'table',
+ datasource: '1__table',
+ groupby: ['name', 'gender'],
+ metrics: ['count'],
+ all_columns: ['name'],
+ row_limit: 100,
+ };
+ const controls = getControlsState(
+ baseState,
+ formData as unknown as Parameters<typeof getControlsState>[1],
+ );
+ // Control states carry mapStateToProps functions, so the default
+ // serializability check would only add noise.
+ return configureStore({
+ reducer: reducerIndex,
+ preloadedState: {
+ explore: {
+ ...baseState,
+ controls,
+ form_data: getFormDataFromControls(
+ controls as ControlPanelsContainerProps['controls'],
+ ),
+ },
+ } as never,
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware({
+ serializableCheck: false,
+ immutableCheck: false,
+ }),
+ devTools: false,
+ });
+ }
+
+ test('switching viz_type re-renders the control sections of the new viz',
async () => {
+ const store = createStoreBackedExplore();
+ render(<StoreBackedControlPanelsContainer />, { store });
+
+ expect(
+ await screen.findByRole('button', { name: /group by/i }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/series limit/i)).not.toBeInTheDocument();
+ expect(
+ (store.getState() as ExplorePageState).explore.controls.limit,
+ ).toBeUndefined();
+
+ act(() => {
+ store.dispatch(setControlValue('viz_type', 'switch-target'));
+ });
+
+ expect(
+ (await screen.findAllByText(/series limit/i)).length,
+ ).toBeGreaterThan(0);
+ expect(
+ screen.queryByRole('button', { name: /group by/i }),
+ ).not.toBeInTheDocument();
+ // The label above comes from the registered panel config, so also check
the
+ // reducer built control state for `limit`, which only the target viz has.
+ const { controls } = (store.getState() as ExplorePageState).explore;
+ expect(controls.limit).toMatchObject({ label: 'Series limit' });
+ });
+
+ test('switching viz_type carries shared metrics and groupby over and drops
controls the new viz lacks', async () => {
+ const store = createStoreBackedExplore();
+ render(<StoreBackedControlPanelsContainer />, { store });
+ await screen.findByRole('button', { name: /group by/i });
+
+ act(() => {
+ store.dispatch(setControlValue('viz_type', 'switch-target'));
+ });
+ await screen.findAllByText(/series limit/i);
+
+ const { form_data: formData, controls } = (
+ store.getState() as ExplorePageState
+ ).explore;
+ expect(formData.viz_type).toBe('switch-target');
+ expect(formData.groupby).toEqual(['name', 'gender']);
+ expect(formData.metrics).toEqual(['count']);
+ expect(formData.standardizedFormData.controls).toEqual({
+ metrics: ['count'],
+ columns: ['name', 'gender'],
+ });
+ expect(controls.all_columns).toBeUndefined();
+ expect(formData.all_columns).toBeUndefined();
+ });
+
+ test('switching viz_type hands standardized metrics and columns to the
target formDataOverrides', async () => {
+ const store = createStoreBackedExplore();
+ render(<StoreBackedControlPanelsContainer />, { store });
+ await screen.findByRole('button', { name: /group by/i });
+
+ act(() => {
+ store.dispatch(setControlValue('viz_type', 'switch-mapped'));
+ });
+
+ const { form_data: formData } = (store.getState() as ExplorePageState)
+ .explore;
+ expect(formData.viz_type).toBe('switch-mapped');
+ expect(formData.size).toBe('count');
+ expect(formData.series_columns).toEqual(['name', 'gender']);
+ });
+
function withHeaderGroupsSync(
overrides: Partial<ControlPanelsContainerProps> = {},
) {
diff --git
a/superset-frontend/src/explore/controlUtils/getSectionsToRender.test.ts
b/superset-frontend/src/explore/controlUtils/getSectionsToRender.test.ts
new file mode 100644
index 00000000000..f2a5c0dfd46
--- /dev/null
+++ b/superset-frontend/src/explore/controlUtils/getSectionsToRender.test.ts
@@ -0,0 +1,179 @@
+/**
+ * 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,
+ getChartControlPanelRegistry,
+} from '@superset-ui/core';
+import {
+ ControlPanelConfig,
+ sharedControls,
+} from '@superset-ui/chart-controls';
+import { getSectionsToRender } from './getSectionsToRender';
+
+const VIZ_TYPE = 'sections-to-render-test-viz';
+const SECTION_LABEL = 'Time section under test';
+
+type ExpandedRow = Array<string | { name?: string } | null | undefined>;
+
+const controlConfig: ControlPanelConfig = {
+ controlPanelSections: [
+ {
+ label: SECTION_LABEL,
+ expanded: true,
+ controlSetRows: [
+ ['granularity'],
+ ['granularity_sqla', 'time_grain_sqla'],
+ ['metrics'],
+ ],
+ },
+ ],
+};
+
+function namesInSection(datasourceType: DatasourceType): string[] {
+ const section = getSectionsToRender(VIZ_TYPE, datasourceType).find(
+ item => item?.label === SECTION_LABEL,
+ );
+ if (!section) {
+ throw new Error(`Section "${SECTION_LABEL}" was not rendered`);
+ }
+ return (section.controlSetRows as ExpandedRow[])
+ .flat()
+ .map(item => (typeof item === 'string' ? item : item?.name))
+ .filter((name): name is string => Boolean(name));
+}
+
+const OBJECT_FORM_SECTION_LABEL = 'Object form controls';
+
+// Mix of string references (filtered by datasource type) and the object form
+// that real chart plugins use for the same controls.
+const objectFormConfig: ControlPanelConfig = {
+ controlPanelSections: [
+ {
+ label: OBJECT_FORM_SECTION_LABEL,
+ expanded: true,
+ controlSetRows: [
+ ['granularity'],
+ ['granularity_sqla'],
+ [{ name: 'granularity', config: sharedControls.granularity }],
+ [
+ {
+ name: 'granularity_sqla',
+ config: sharedControls.granularity_sqla,
+ },
+ { name: 'time_grain_sqla', config: sharedControls.time_grain_sqla },
+ ],
+ ],
+ },
+ ],
+};
+
+beforeEach(() => {
+ getChartControlPanelRegistry().registerValue(VIZ_TYPE, controlConfig);
+});
+
+afterEach(() => {
+ getChartControlPanelRegistry().remove(VIZ_TYPE);
+});
+
+test.each([
+ DatasourceType.Table,
+ DatasourceType.Query,
+ DatasourceType.SemanticView,
+])(
+ 'hides the legacy granularity control and keeps the sqla time controls for
%s datasources',
+ datasourceType => {
+ const names = namesInSection(datasourceType);
+
+ expect(names).not.toContain('granularity');
+ expect(names).toEqual(
+ expect.arrayContaining(['granularity_sqla', 'time_grain_sqla']),
+ );
+ },
+);
+
+test.each([
+ DatasourceType.Dataset,
+ DatasourceType.SlTable,
+ DatasourceType.SavedQuery,
+])(
+ 'hides granularity_sqla and time_grain_sqla and keeps granularity for %s
datasources',
+ datasourceType => {
+ const names = namesInSection(datasourceType);
+
+ expect(names).toContain('granularity');
+ expect(names).not.toContain('granularity_sqla');
+ expect(names).not.toContain('time_grain_sqla');
+ },
+);
+
+test('keeps controls that are not datasource specific for every datasource
type', () => {
+ [DatasourceType.Table, DatasourceType.Dataset].forEach(datasourceType => {
+ expect(namesInSection(datasourceType)).toContain('metrics');
+ });
+});
+
+test('always leads with the datasource and viz type section', () => {
+ const [first] = getSectionsToRender(VIZ_TYPE, DatasourceType.Table);
+ const rows = (first?.controlSetRows ?? []) as ExpandedRow[];
+ const names = rows
+ .flat()
+ .map(item => (typeof item === 'string' ? item : item?.name));
+
+ expect(names).toEqual(expect.arrayContaining(['datasource', 'viz_type']));
+});
+
+test('returns only the default sections for an unregistered viz type', () => {
+ const sections = getSectionsToRender(
+ 'unregistered-viz-type',
+ DatasourceType.Table,
+ );
+
+ expect(sections.map(section => section?.label)).not.toContain(SECTION_LABEL);
+ expect(sections.length).toBeGreaterThan(0);
+});
+
+test.each([
+ [
+ DatasourceType.Table,
+ ['granularity_sqla', 'granularity', 'granularity_sqla', 'time_grain_sqla'],
+ ],
+ [
+ DatasourceType.SemanticView,
+ ['granularity_sqla', 'granularity', 'granularity_sqla', 'time_grain_sqla'],
+ ],
+ [
+ DatasourceType.Dataset,
+ ['granularity', 'granularity', 'granularity_sqla', 'time_grain_sqla'],
+ ],
+])(
+ 'documents that only string control references are filtered, object-form
controls pass through, for %s datasources',
+ (datasourceType, expectedNames) => {
+ getChartControlPanelRegistry().registerValue(VIZ_TYPE, objectFormConfig);
+
+ const section = getSectionsToRender(VIZ_TYPE, datasourceType).find(
+ item => item?.label === OBJECT_FORM_SECTION_LABEL,
+ );
+ const rows = (section?.controlSetRows ?? []) as ExpandedRow[];
+ const names = rows
+ .flat()
+ .map(item => (typeof item === 'string' ? item : item?.name));
+
+ expect(names).toEqual(expectedNames);
+ },
+);
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.test.ts
b/superset-frontend/src/explore/reducers/exploreReducer.test.ts
index ac8629eb00f..b5fb7620491 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.test.ts
+++ b/superset-frontend/src/explore/reducers/exploreReducer.test.ts
@@ -23,9 +23,13 @@ import {
getChartControlPanelRegistry,
} from '@superset-ui/core';
import {
- sections,
+ ControlConfig,
+ ControlPanelState,
+ ControlState,
CustomControlItem,
Dataset,
+ sections,
+ sharedControls,
} from '@superset-ui/chart-controls';
import {
getControlStateFromControlConfig,
@@ -180,6 +184,205 @@ test('SET_FIELD_VALUE clears the custom-shift date error
when time_compare leave
expect(afterSwitch.controls.start_date_offset.validationErrors).toEqual([]);
});
+type ReducerAction = Parameters<typeof exploreReducer>[1];
+
+type MirrorControlState = ControlState & { mirroredValue?: unknown };
+
+// A control whose derived props depend on another control's current value.
+const mirrorOf = (sourceControl: string): ControlConfig =>
+ ({
+ type: 'TextControl',
+ mapStateToProps: (state: ControlPanelState) => ({
+ mirroredValue: state.controls?.[sourceControl]?.value,
+ }),
+ }) as unknown as ControlConfig;
+
+const buildControl = (
+ config: ControlConfig,
+ formData: QueryFormData,
+ value: unknown,
+ controls: Record<string, ControlState> = {},
+) =>
+ getControlStateFromControlConfig(
+ config,
+ { controls, form_data: formData },
+ value as never,
+ )!;
+
+test('SET_FIELD_VALUE re-derives controls listed in `rerender` from the
updated control value', () => {
+ const form_data = {
+ matrixify_mode_rows: 'disabled',
+ matrixify_mode_columns: 'disabled',
+ } as unknown as QueryFormData;
+ const initialState: ExploreState = {
+ form_data,
+ controls: {
+ matrixify_mode_rows: buildControl(
+ sharedControls.matrixify_mode_rows as unknown as ControlConfig,
+ form_data,
+ 'disabled',
+ ),
+ matrixify_mode_columns: buildControl(
+ sharedControls.matrixify_mode_columns as unknown as ControlConfig,
+ form_data,
+ 'disabled',
+ ),
+ matrixify_dimension_rows: buildControl(
+ sharedControls.matrixify_dimension_rows as unknown as ControlConfig,
+ form_data,
+ { dimension: '', values: [] },
+ ),
+ },
+ };
+ const metricsOption = (state: ExploreState) =>
+ (
+ state.controls.matrixify_mode_columns.options as Array<{
+ value: string;
+ disabled?: boolean;
+ }>
+ ).find(option => option.value === 'metrics');
+
+ // Columns offer "metrics" while the rows axis is not using it.
+ expect(metricsOption(initialState)?.disabled).toBe(false);
+
+ const newState = exploreReducer(
+ initialState,
+ setControlValue('matrixify_mode_rows', 'metrics') as ReducerAction,
+ );
+
+ // The rows axis now owns "metrics", so the columns control must be
+ // re-derived without the user touching it.
+ expect(metricsOption(newState)?.disabled).toBe(true);
+ expect(newState.controls.matrixify_mode_columns.value).toBe('disabled');
+});
+
+test('SET_FIELD_VALUE leaves controls that are not listed in `rerender`
stale', () => {
+ const form_data = { source: 'a' } as unknown as QueryFormData;
+ const plain = { type: 'TextControl' } as unknown as ControlConfig;
+ const buildState = (rerender: string[]): ExploreState => {
+ const source = buildControl(
+ { ...plain, rerender } as unknown as ControlConfig,
+ form_data,
+ 'a',
+ );
+ const controls = { source };
+ return {
+ form_data,
+ controls: {
+ source,
+ listed: buildControl(mirrorOf('source'), form_data, null, controls),
+ unlisted: buildControl(mirrorOf('source'), form_data, null, controls),
+ },
+ };
+ };
+ const mirrored = (state: ExploreState, name: string) =>
+ (state.controls[name] as MirrorControlState).mirroredValue;
+
+ const initialState = buildState(['listed']);
+ expect(mirrored(initialState, 'listed')).toBe('a');
+ expect(mirrored(initialState, 'unlisted')).toBe('a');
+
+ const newState = exploreReducer(
+ initialState,
+ setControlValue('source', 'b') as ReducerAction,
+ );
+
+ expect(mirrored(newState, 'listed')).toBe('b');
+ expect(mirrored(newState, 'unlisted')).toBe('a');
+});
+
+test('SET_FIELD_VALUE only re-validates controls that declare the changed
control in `validationDependencies`', () => {
+ const STALE_ERROR = 'Driven by the changed control';
+ const requiresOk = (declaresDependency: boolean): ControlConfig =>
+ ({
+ type: 'TextControl',
+ mapStateToProps: (state: ControlPanelState) => ({
+ externalValidationErrors:
+ state.form_data.source === 'bad' ? [STALE_ERROR] : [],
+ }),
+ ...(declaresDependency && { validationDependencies: ['source'] }),
+ }) as unknown as ControlConfig;
+
+ const form_data = { source: 'bad' } as unknown as QueryFormData;
+ const initialState: ExploreState = {
+ form_data,
+ controls: {
+ source: buildControl(
+ { type: 'TextControl' } as unknown as ControlConfig,
+ form_data,
+ 'bad',
+ ),
+ declared: buildControl(requiresOk(true), form_data, 'x'),
+ undeclared: buildControl(requiresOk(false), form_data, 'x'),
+ },
+ };
+
+ // Both dependents start out in error.
+ expect(initialState.controls.declared.validationErrors).toEqual([
+ STALE_ERROR,
+ ]);
+ expect(initialState.controls.undeclared.validationErrors).toEqual([
+ STALE_ERROR,
+ ]);
+
+ const newState = exploreReducer(
+ initialState,
+ setControlValue('source', 'good') as ReducerAction,
+ );
+
+ // The declared dependent re-validates against the new value; the other one
+ // keeps the error because nothing tells the reducer to re-run it.
+ expect(newState.controls.declared.validationErrors).toEqual([]);
+ expect(newState.controls.undeclared.validationErrors).toEqual([STALE_ERROR]);
+});
+
+test('SET_FIELD_VALUE raises a dependent control error when the changed
control makes it invalid', () => {
+ const REQUIRED_DATE_ERROR = 'A date is required when using custom date
shift';
+ const timeComparisonSection = sections.timeComparisonControls({
+ multi: false,
+ showCalculationType: false,
+ showFullChoices: false,
+ });
+ const timeCompareConfig = (
+ timeComparisonSection.controlSetRows[0][0] as CustomControlItem
+ ).config;
+ const startDateOffsetConfig = (
+ timeComparisonSection.controlSetRows[1][0] as CustomControlItem
+ ).config;
+ const form_data = {
+ time_compare: '1 week ago',
+ start_date_offset: '',
+ } as unknown as QueryFormData;
+ const controlPanelState = { controls: {}, form_data };
+ const initialState: ExploreState = {
+ form_data,
+ controls: {
+ time_compare: getControlStateFromControlConfig(
+ timeCompareConfig,
+ controlPanelState,
+ '1 week ago',
+ )!,
+ start_date_offset: getControlStateFromControlConfig(
+ startDateOffsetConfig,
+ controlPanelState,
+ '',
+ )!,
+ },
+ };
+
+ // An empty start date is fine until the shift becomes "custom".
+ expect(initialState.controls.start_date_offset.validationErrors).toEqual([]);
+
+ const newState = exploreReducer(
+ initialState,
+ setControlValue('time_compare', 'custom') as ReducerAction,
+ );
+
+ expect(newState.controls.start_date_offset.validationErrors).toEqual([
+ REQUIRED_DATE_ERROR,
+ ]);
+});
+
test('explicit semantic reset removes stale fields and stash and persists only
new generation', () => {
const datasource = {
id: 7,