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