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,

Reply via email to