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); +});
