This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch test/native-filter-bar-runtime
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 57e7176f22724945128f5bd02e2e84c078f287d3
Author: Joe Li <[email protected]>
AuthorDate: Fri Sep 25 15:59:18 2026 -0700

    test(dashboard): cover native filter bar key persistence and state hooks
    
    Adds regression coverage for the native filter bar runtime:
    - Filter state key API helpers: request shape, value parsing, and null on 
error
    - FilterBar creates a new filter state key when none exists and updates the 
existing key on Apply
    - FilterBar state hooks: applied data mask selection, stale selection 
cleanup, and initialization readiness
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../nativeFilters/FilterBar/FilterBar.test.tsx     | 169 ++++++++++++++++
 .../nativeFilters/FilterBar/keyValue.test.ts       | 147 ++++++++++++++
 .../nativeFilters/FilterBar/state.test.ts          | 220 +++++++++++++++++++++
 3 files changed, 536 insertions(+)

diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
index f8cb10d4da6..1be8cc7d2d8 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
@@ -83,6 +83,13 @@ beforeEach(() => {
   jest.clearAllMocks();
 });
 
+afterEach(() => {
+  fetchMock.removeRoute('index-create-filter-key');
+  fetchMock.removeRoute('index-create-filter-key-2');
+  fetchMock.removeRoute('index-update-filter-key');
+  window.history.pushState(null, '', '/');
+});
+
 const getTestId = testWithId<string>(FILTER_BAR_TEST_ID, true);
 const getModalTestId = testWithId<string>(FILTERS_CONFIG_MODAL_TEST_ID, true);
 
@@ -1230,6 +1237,168 @@ test('required filter with a default value auto-applies 
on load without touching
   updateDataMaskSpy.mockRestore();
 });
 
+test('FilterBar POSTs a new filter state key and writes it to the URL when 
none exists', async () => {
+  window.history.pushState(null, '', '/dashboard/1/');
+
+  fetchMock.post(
+    'glob:*/api/v1/dashboard/1/filter_state*',
+    { key: 'brand-new-key' },
+    { name: 'index-create-filter-key' },
+  );
+  // Isolate this test's assertions from any debounced publishDataMask call
+  // left pending by an earlier test sharing the same module-level debounce.
+  fetchMock.clearHistory();
+
+  const filterId = 'NATIVE_FILTER-create-key-test';
+  const selectFilter = createFilter({
+    id: filterId,
+    name: 'Region',
+    filterType: 'filter_select',
+    targets: [{ datasetId: 7, column: { name: 'region' } }],
+    defaultDataMask: { filterState: { value: null }, extraFormData: {} },
+    chartsInScope: [18],
+  });
+  const stateWithSelect = {
+    ...stateWithoutNativeFilters,
+    dashboardInfo: {
+      id: 1,
+      dash_edit_perm: true,
+      filterBarOrientation: FilterBarOrientation.Vertical,
+      metadata: {
+        native_filter_configuration: [selectFilter],
+        chart_configuration: {},
+      },
+    },
+    dashboardState: {
+      ...stateWithoutNativeFilters.dashboardState,
+      activeTabs: ['ROOT_ID'],
+    },
+    dataMask: {
+      [filterId]: createDataMask(filterId, ['East'], {
+        filters: [{ col: 'region', op: 'IN', val: ['East'] }],
+      }),
+    },
+    nativeFilters: {
+      filters: { [filterId]: selectFilter },
+      filtersState: {},
+    },
+  };
+
+  const props = createOpenedBarProps();
+  renderFilterBar(props, stateWithSelect);
+  await act(async () => {
+    jest.advanceTimersByTime(1000);
+  });
+
+  await waitFor(() => {
+    expect(
+      fetchMock.callHistory.calls('index-create-filter-key').length,
+    ).toBeGreaterThanOrEqual(1);
+  });
+  const createCalls = fetchMock.callHistory.calls('index-create-filter-key');
+  const createCall = createCalls[createCalls.length - 1];
+  expect(createCall.options?.method).toBe('post');
+  expect(JSON.parse(createCall.options?.body as string).value).toContain(
+    'East',
+  );
+
+  await waitFor(() => {
+    expect(window.location.search).toContain(
+      'native_filters_key=brand-new-key',
+    );
+  });
+});
+
+test('FilterBar PUTs the filter state key already present in the URL on Apply 
instead of creating a new one', async () => {
+  window.history.pushState(null, '', '/dashboard/1/');
+
+  fetchMock.post(
+    'glob:*/api/v1/dashboard/1/filter_state*',
+    { key: 'mount-key' },
+    { name: 'index-create-filter-key-2' },
+  );
+  fetchMock.put(
+    'glob:*/api/v1/dashboard/1/filter_state/mount-key*',
+    { message: 'Value updated' },
+    { name: 'index-update-filter-key' },
+  );
+  fetchMock.clearHistory();
+
+  const filterId = 'NATIVE_FILTER-update-key-test';
+  const selectFilter = createFilter({
+    id: filterId,
+    name: 'Region',
+    filterType: 'filter_select',
+    targets: [{ datasetId: 7, column: { name: 'region' } }],
+    defaultDataMask: { filterState: { value: null }, extraFormData: {} },
+    chartsInScope: [18],
+  });
+  const stateWithSelect = {
+    ...stateWithoutNativeFilters,
+    dashboardInfo: {
+      id: 1,
+      dash_edit_perm: true,
+      filterBarOrientation: FilterBarOrientation.Vertical,
+      metadata: {
+        native_filter_configuration: [selectFilter],
+        chart_configuration: {},
+      },
+    },
+    dashboardState: {
+      ...stateWithoutNativeFilters.dashboardState,
+      activeTabs: ['ROOT_ID'],
+    },
+    dataMask: {
+      [filterId]: createDataMask(filterId, ['East'], {
+        filters: [{ col: 'region', op: 'IN', val: ['East'] }],
+      }),
+    },
+    nativeFilters: {
+      filters: { [filterId]: selectFilter },
+      filtersState: {},
+    },
+  };
+
+  const props = createOpenedBarProps();
+  renderFilterBar(props, stateWithSelect);
+  await act(async () => {
+    jest.advanceTimersByTime(1000);
+  });
+
+  // The initial mount always creates a fresh key (the update-vs-create branch
+  // only takes the update path once an Apply has happened in this session).
+  await waitFor(() => {
+    expect(window.location.search).toContain('native_filters_key=mount-key');
+  });
+  fetchMock.clearHistory();
+
+  const clearBtn = screen.getByTestId(getTestId('clear-button'));
+  expect(clearBtn).not.toBeDisabled();
+  await act(async () => {
+    await userEvent.click(clearBtn);
+  });
+  const applyBtn = screen.getByTestId(getTestId('apply-button'));
+  expect(applyBtn).not.toBeDisabled();
+  await act(async () => {
+    await userEvent.click(applyBtn);
+  });
+
+  await waitFor(() => {
+    expect(
+      fetchMock.callHistory.calls('index-update-filter-key').length,
+    ).toBeGreaterThanOrEqual(1);
+  });
+  
expect(fetchMock.callHistory.calls('index-create-filter-key-2')).toHaveLength(
+    0,
+  );
+  const updateCalls = fetchMock.callHistory.calls('index-update-filter-key');
+  const updateCall = updateCalls[updateCalls.length - 1];
+  const updatedDataMask = JSON.parse(
+    JSON.parse(updateCall.options?.body as string).value,
+  );
+  expect(updatedDataMask[filterId].filterState.value).toBeNull();
+});
+
 test('FilterBar with orientation=Vertical renders Vertical layout (sanity 
counterpart to the horizontal routing test)', () => {
   // Paired control for the routing test above: with Vertical orientation,
   // the settings gear must NOT be present (Vertical.tsx does not render
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/keyValue.test.ts
 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/keyValue.test.ts
new file mode 100644
index 00000000000..16d6d3c22b6
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/keyValue.test.ts
@@ -0,0 +1,147 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+import { logging } from '@apache-superset/core/utils';
+import {
+  updateFilterKey,
+  createFilterKey,
+  getFilterValue,
+  getPermalinkValue,
+} from './keyValue';
+
+jest.mock('@apache-superset/core/utils', () => ({
+  logging: { error: jest.fn() },
+}));
+
+afterEach(() => {
+  fetchMock.removeRoutes();
+  fetchMock.clearHistory();
+  jest.clearAllMocks();
+});
+
+test('updateFilterKey PUTs the value to the dashboard filter_state key 
endpoint and resolves the message', async () => {
+  fetchMock.put(
+    'glob:*/api/v1/dashboard/7/filter_state/existing-key',
+    { message: 'Value updated' },
+    { name: 'update-filter-key' },
+  );
+
+  const result = await updateFilterKey('7', '{"a":1}', 'existing-key');
+
+  const calls = fetchMock.callHistory.calls('update-filter-key');
+  expect(calls).toHaveLength(1);
+  expect(calls[0].options?.method).toBe('put');
+  expect(JSON.parse(calls[0].options?.body as string)).toEqual({
+    value: '{"a":1}',
+  });
+  expect(result).toBe('Value updated');
+});
+
+test('updateFilterKey appends the tab id as a query param', async () => {
+  fetchMock.put(
+    'glob:*/api/v1/dashboard/7/filter_state/existing-key?tab_id=tab-1',
+    { message: 'Value updated' },
+    { name: 'update-filter-key-tab' },
+  );
+
+  await updateFilterKey('7', '{"a":1}', 'existing-key', 'tab-1');
+
+  expect(fetchMock.callHistory.calls('update-filter-key-tab')).toHaveLength(1);
+});
+
+test('updateFilterKey logs and resolves null when the request fails', async () 
=> {
+  fetchMock.put('glob:*/api/v1/dashboard/7/filter_state/existing-key', 500);
+
+  const result = await updateFilterKey('7', '{"a":1}', 'existing-key');
+
+  expect(result).toBeNull();
+  expect(logging.error).toHaveBeenCalled();
+});
+
+test('createFilterKey POSTs the value to the dashboard filter_state endpoint 
and resolves the new key', async () => {
+  fetchMock.post(
+    'glob:*/api/v1/dashboard/7/filter_state',
+    { key: 'brand-new-key' },
+    { name: 'create-filter-key' },
+  );
+
+  const result = await createFilterKey(7, '{"a":1}');
+
+  const calls = fetchMock.callHistory.calls('create-filter-key');
+  expect(calls).toHaveLength(1);
+  expect(calls[0].options?.method).toBe('post');
+  expect(JSON.parse(calls[0].options?.body as string)).toEqual({
+    value: '{"a":1}',
+  });
+  expect(result).toBe('brand-new-key');
+});
+
+test('createFilterKey logs and resolves null when the request fails', async () 
=> {
+  fetchMock.post('glob:*/api/v1/dashboard/7/filter_state', 500);
+
+  const result = await createFilterKey(7, '{"a":1}');
+
+  expect(result).toBeNull();
+  expect(logging.error).toHaveBeenCalled();
+});
+
+test('getFilterValue GETs the keyed filter_state and parses the stringified 
value', async () => {
+  fetchMock.get(
+    'glob:*/api/v1/dashboard/7/filter_state/some-key',
+    { value: '{"b":2}' },
+    { name: 'get-filter-value' },
+  );
+
+  const result = await getFilterValue(7, 'some-key');
+
+  expect(fetchMock.callHistory.calls('get-filter-value')).toHaveLength(1);
+  expect(result).toEqual({ b: 2 });
+});
+
+test('getFilterValue logs and resolves null when the request fails', async () 
=> {
+  fetchMock.get('glob:*/api/v1/dashboard/7/filter_state/some-key', 404);
+
+  const result = await getFilterValue(7, 'some-key');
+
+  expect(result).toBeNull();
+  expect(logging.error).toHaveBeenCalled();
+});
+
+test('getPermalinkValue GETs the dashboard permalink endpoint and resolves the 
parsed payload', async () => {
+  const permalinkPayload = { dashboardId: '7', state: {} };
+  fetchMock.get(
+    'glob:*/api/v1/dashboard/permalink/perma-key',
+    permalinkPayload,
+    { name: 'get-permalink-value' },
+  );
+
+  const result = await getPermalinkValue('perma-key');
+
+  expect(fetchMock.callHistory.calls('get-permalink-value')).toHaveLength(1);
+  expect(result).toEqual(permalinkPayload);
+});
+
+test('getPermalinkValue logs and resolves null when the request fails', async 
() => {
+  fetchMock.get('glob:*/api/v1/dashboard/permalink/perma-key', 500);
+
+  const result = await getPermalinkValue('perma-key');
+
+  expect(result).toBeNull();
+  expect(logging.error).toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.test.ts
 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.test.ts
new file mode 100644
index 00000000000..c5d76f304ff
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.test.ts
@@ -0,0 +1,220 @@
+/**
+ * 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 { act, renderHook } from '@testing-library/react';
+import { useSelector } from 'react-redux';
+import { DataMaskState, DataMaskStateWithId } from '@superset-ui/core';
+import {
+  useAllAppliedDataMask,
+  useFilterUpdates,
+  useInitialization,
+  useNativeFiltersDataMask,
+} from './state';
+
+jest.mock('react-redux', () => ({
+  ...jest.requireActual('react-redux'),
+  useSelector: jest.fn(),
+}));
+
+const mockUseSelector = (mockState: object) => {
+  (useSelector as jest.Mock).mockImplementation(
+    (selector: (state: object) => unknown) => selector(mockState),
+  );
+};
+
+afterEach(() => {
+  jest.useRealTimers();
+  jest.clearAllMocks();
+  document
+    .querySelectorAll('[data-ui-anchor="chart"]')
+    .forEach(node => node.remove());
+});
+
+test('useNativeFiltersDataMask keeps only entries whose id carries the native 
filter prefix', () => {
+  const dataMask: DataMaskStateWithId = {
+    'NATIVE_FILTER-1': { id: 'NATIVE_FILTER-1', extraFormData: {} },
+    'CHART_CUSTOMIZATION-1': {
+      id: 'CHART_CUSTOMIZATION-1',
+      extraFormData: {},
+    },
+    groupby_1: { id: 'groupby_1', extraFormData: {} },
+  } as unknown as DataMaskStateWithId;
+  mockUseSelector({ dataMask });
+
+  const { result } = renderHook(() => useNativeFiltersDataMask());
+
+  expect(Object.keys(result.current)).toEqual(['NATIVE_FILTER-1']);
+});
+
+test('useAllAppliedDataMask keeps native filter, chart customization, and 
legacy groupby entries', () => {
+  const dataMask: DataMaskStateWithId = {
+    'NATIVE_FILTER-1': { id: 'NATIVE_FILTER-1', extraFormData: {} },
+    'CHART_CUSTOMIZATION-1': {
+      id: 'CHART_CUSTOMIZATION-1',
+      extraFormData: {},
+    },
+    groupby_1: { id: 'groupby_1', extraFormData: {} },
+    'UNRELATED-1': { id: 'UNRELATED-1', extraFormData: {} },
+  } as unknown as DataMaskStateWithId;
+  mockUseSelector({ dataMask });
+
+  const { result } = renderHook(() => useAllAppliedDataMask());
+
+  expect(Object.keys(result.current).sort()).toEqual([
+    'CHART_CUSTOMIZATION-1',
+    'NATIVE_FILTER-1',
+    'groupby_1',
+  ]);
+});
+
+test('useFilterUpdates removes a selected id that no longer maps to a known 
filter', () => {
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: { metadata: { native_filter_configuration: [] } },
+    dataMask: {},
+  });
+  const dataMaskSelected: DataMaskState = {
+    'NATIVE_FILTER-stale': { extraFormData: {} },
+  };
+  const setDataMaskSelected = jest.fn();
+
+  renderHook(() => useFilterUpdates(dataMaskSelected, setDataMaskSelected));
+
+  expect(setDataMaskSelected).toHaveBeenCalledTimes(1);
+  const draft: Record<string, unknown> = {
+    'NATIVE_FILTER-stale': { extraFormData: {} },
+  };
+  setDataMaskSelected.mock.calls[0][0](draft);
+  expect(draft).not.toHaveProperty('NATIVE_FILTER-stale');
+});
+
+test('useFilterUpdates leaves a selected id that still maps to a known 
filter', () => {
+  const filter = {
+    id: 'NATIVE_FILTER-known',
+    name: 'Known filter',
+    filterType: 'filter_select',
+  };
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: {
+      metadata: { native_filter_configuration: [filter] },
+    },
+    dataMask: {},
+  });
+  const dataMaskSelected: DataMaskState = {
+    'NATIVE_FILTER-known': { extraFormData: {} },
+  };
+  const setDataMaskSelected = jest.fn();
+
+  renderHook(() => useFilterUpdates(dataMaskSelected, setDataMaskSelected));
+
+  expect(setDataMaskSelected).not.toHaveBeenCalled();
+});
+
+test('useFilterUpdates leaves a chart customization id even when it is not a 
known filter', () => {
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: { metadata: { native_filter_configuration: [] } },
+    dataMask: {},
+  });
+  const dataMaskSelected: DataMaskState = {
+    'CHART_CUSTOMIZATION-1': { extraFormData: {} },
+  };
+  const setDataMaskSelected = jest.fn();
+
+  renderHook(() => useFilterUpdates(dataMaskSelected, setDataMaskSelected));
+
+  expect(setDataMaskSelected).not.toHaveBeenCalled();
+});
+
+test('useInitialization is true immediately when a filter requires 
first-load', () => {
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: {
+      metadata: {
+        native_filter_configuration: [
+          { id: 'NATIVE_FILTER-1', name: 'F1', requiredFirst: true },
+        ],
+      },
+    },
+    charts: {},
+  });
+
+  const { result } = renderHook(() => useInitialization());
+
+  expect(result.current).toBe(true);
+});
+
+test('useInitialization becomes true once every loading chart on the page 
finishes loading', () => {
+  document.body.innerHTML =
+    '<div data-ui-anchor="chart"></div><div data-ui-anchor="chart"></div>';
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: { metadata: { native_filter_configuration: [] } },
+    charts: {
+      1: { chartStatus: 'rendered' },
+      2: { chartStatus: 'rendered' },
+    },
+  });
+
+  const { result } = renderHook(() => useInitialization());
+
+  expect(result.current).toBe(true);
+});
+
+test('useInitialization stays false while charts are still loading', () => {
+  jest.useFakeTimers();
+  document.body.innerHTML =
+    '<div data-ui-anchor="chart"></div><div data-ui-anchor="chart"></div>';
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: { metadata: { native_filter_configuration: [] } },
+    charts: {
+      1: { chartStatus: 'loading' },
+      2: { chartStatus: 'rendered' },
+    },
+  });
+
+  const { result } = renderHook(() => useInitialization());
+  expect(result.current).toBe(false);
+
+  // The no-charts fallback timer must not flip it while a chart is loading.
+  act(() => {
+    jest.advanceTimersByTime(1000);
+  });
+
+  expect(result.current).toBe(false);
+});
+
+test('useInitialization falls back to true after a 1s timeout when there are 
no charts on the page', () => {
+  jest.useFakeTimers();
+  mockUseSelector({
+    dashboardState: { preselectNativeFilters: {} },
+    dashboardInfo: { metadata: { native_filter_configuration: [] } },
+    charts: {},
+  });
+
+  const { result } = renderHook(() => useInitialization());
+  expect(result.current).toBe(false);
+
+  act(() => {
+    jest.advanceTimersByTime(1000);
+  });
+
+  expect(result.current).toBe(true);
+});

Reply via email to