From f70a2eac8975e8a43eba6bcf92bd570dee6b5be8 Mon Sep 17 00:00:00 2001 From: yousoph Date: Mon, 22 Jun 2026 15:37:18 -0700 Subject: [PATCH] fix(dashboard): normalize legacy currentState to filterState in native_filters URL param (#40929) Co-authored-by: Claude Sonnet 4.6 --- .../containers/DashboardPage.test.tsx | 118 +++++++++++++++++- .../dashboard/containers/DashboardPage.tsx | 17 ++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx index 98967c1f97d..7795bc95836 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx @@ -38,6 +38,7 @@ import { UPDATE_COMPONENTS, } from 'src/dashboard/actions/dashboardLayout'; import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants'; +import { getUrlParam } from 'src/utils/urlUtils'; import DashboardPage from './DashboardPage'; const mockTheme = { @@ -133,9 +134,11 @@ jest.mock('src/dashboard/util/activeDashboardFilters', () => ({ })); jest.mock('src/utils/urlUtils', () => ({ - getUrlParam: () => null, + getUrlParam: jest.fn().mockReturnValue(null), })); +const mockGetUrlParam = getUrlParam as jest.Mock; + jest.mock('src/dashboard/components/nativeFilters/FilterBar/keyValue', () => ({ getFilterValue: jest.fn(), getPermalinkValue: jest.fn(), @@ -161,6 +164,10 @@ beforeEach(() => { // Tests assert against the global document.title and the unmount restore // effect can carry title state across tests, so reset it for isolation. document.title = ''; + // clearAllMocks does not reset mockImplementation — reset explicitly so + // per-test overrides don't leak into subsequent tests. + mockGetUrlParam.mockReset(); + mockGetUrlParam.mockReturnValue(null); mockUseDashboard.mockReturnValue({ result: mockDashboard, error: null, @@ -443,6 +450,115 @@ test('passes null theme when Redux dashboardInfo.theme is explicitly null (theme ); }); +test('copies currentState to filterState for legacy native_filters URL params', async () => { + // Pre-2021 URLs encode filter selections under `currentState`. The dataMask + // reducer uses `filterState`, so without normalization the filter panel shows + // no active selections even though extraFormData still filters chart queries. + mockGetUrlParam.mockImplementation((param: { name: string }) => { + if (param.name === 'native_filters') { + return { + 'NATIVE_FILTER-OvPTDNKc9': { + extraFormData: { + filters: [{ col: 'team_name', op: 'IN', val: ['MarginEdge'] }], + }, + currentState: { value: ['MarginEdge'] }, + }, + }; + } + return null; + }); + + render( + + + , + { + useRedux: true, + useRouter: true, + initialState: { + dashboardInfo: { id: 1, metadata: {} }, + dashboardState: { sliceIds: [] }, + nativeFilters: { filters: {} }, + dataMask: {}, + }, + }, + ); + + await waitFor(() => { + expect(screen.queryByText('loading')).not.toBeInTheDocument(); + }); + + expect(hydrateDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + dataMask: expect.objectContaining({ + 'NATIVE_FILTER-OvPTDNKc9': expect.objectContaining({ + filterState: { value: ['MarginEdge'] }, + extraFormData: { + filters: [{ col: 'team_name', op: 'IN', val: ['MarginEdge'] }], + }, + currentState: { value: ['MarginEdge'] }, + }), + }), + }), + ); +}); + +test('does not overwrite filterState when modern native_filters URL format is used', async () => { + // Modern URLs already carry `filterState`; the normalization must not clobber it. + mockGetUrlParam.mockImplementation((param: { name: string }) => { + if (param.name === 'native_filters') { + return { + 'NATIVE_FILTER-OvPTDNKc9': { + extraFormData: { + filters: [{ col: 'team_name', op: 'IN', val: ['MarginEdge'] }], + }, + filterState: { value: ['MarginEdge'] }, + }, + }; + } + return null; + }); + + render( + + + , + { + useRedux: true, + useRouter: true, + initialState: { + dashboardInfo: { id: 1, metadata: {} }, + dashboardState: { sliceIds: [] }, + nativeFilters: { filters: {} }, + dataMask: {}, + }, + }, + ); + + await waitFor(() => { + expect(screen.queryByText('loading')).not.toBeInTheDocument(); + }); + + expect(hydrateDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + dataMask: expect.objectContaining({ + 'NATIVE_FILTER-OvPTDNKc9': expect.objectContaining({ + filterState: { value: ['MarginEdge'] }, + extraFormData: { + filters: [{ col: 'team_name', op: 'IN', val: ['MarginEdge'] }], + }, + }), + }), + }), + ); + + // currentState must not have been injected + const callArg = (hydrateDashboard as jest.Mock).mock.calls[0][0]; + expect( + callArg.dataMask['NATIVE_FILTER-OvPTDNKc9'].currentState, + ).toBeUndefined(); +}); + test('clears undo history after hydrating the dashboard', async () => { render( diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index ae86ec63c0a..12a7716ab69 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -223,7 +223,22 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { dataMask = await getFilterValue(id, nativeFilterKeyValue); } if (isOldRison) { - dataMask = isOldRison; + // Normalize legacy `currentState` → `filterState`. Pre-2021 URLs stored + // per-filter selections under `currentState`; modern dataMask uses + // `filterState`. Without this copy the filter panel shows no active + // selections even though extraFormData still applies the query filter. + if (typeof isOldRison === 'object' && isOldRison !== null) { + dataMask = Object.fromEntries( + Object.entries( + isOldRison as Record>, + ).map(([filterId, entry]) => [ + filterId, + entry?.currentState && !entry?.filterState + ? { ...entry, filterState: entry.currentState } + : entry, + ]), + ); + } } // Parse Rison URL filters with intelligent native filter injection