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

jli 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 939e4194c6 fix(alerts): fix error toast when editing report with saved 
tab selection (#38198)
939e4194c6 is described below

commit 939e4194c6171ddfded43186dabe8471760c70fe
Author: Joe Li <[email protected]>
AuthorDate: Wed Mar 4 13:01:26 2026 -0800

    fix(alerts): fix error toast when editing report with saved tab selection 
(#38198)
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .../src/features/alerts/AlertReportModal.test.tsx  | 467 ++++++++++++++++++++-
 .../src/features/alerts/AlertReportModal.tsx       |  40 +-
 superset-frontend/src/features/alerts/types.ts     |   7 +
 3 files changed, 493 insertions(+), 21 deletions(-)

diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx 
b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
index 585fd7c2bf..b025a27c01 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
@@ -24,7 +24,9 @@ import {
   userEvent,
   waitFor,
   within,
+  createStore,
 } from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
 import { buildErrorTooltipMessage } from './buildErrorTooltipMessage';
 import AlertReportModal, { AlertReportModalProps } from './AlertReportModal';
 import { AlertObject, NotificationMethodOption } from './types';
@@ -109,8 +111,16 @@ const FETCH_REPORT_WITH_FILTERS_ENDPOINT = 
'glob:*/api/v1/report/3';
 const FETCH_REPORT_NO_FILTER_NAME_ENDPOINT = 'glob:*/api/v1/report/4';
 const FETCH_REPORT_OVERWRITE_ENDPOINT = 'glob:*/api/v1/report/5';
 
-fetchMock.get(FETCH_DASHBOARD_ENDPOINT, { result: generateMockPayload(true) });
-fetchMock.get(FETCH_CHART_ENDPOINT, { result: generateMockPayload(false) });
+fetchMock.get(
+  FETCH_DASHBOARD_ENDPOINT,
+  { result: generateMockPayload(true) },
+  { name: FETCH_DASHBOARD_ENDPOINT },
+);
+fetchMock.get(
+  FETCH_CHART_ENDPOINT,
+  { result: generateMockPayload(false) },
+  { name: FETCH_CHART_ENDPOINT },
+);
 fetchMock.get(FETCH_REPORT_WITH_FILTERS_ENDPOINT, {
   result: {
     ...generateMockPayload(true),
@@ -839,7 +849,7 @@ test('filter reappears in dropdown after clearing with X 
icon', async () => {
         },
       },
     },
-    { name: 'clear-icon-tabs' },
+    { name: tabsEndpoint },
   );
 
   fetchMock.post(
@@ -902,6 +912,457 @@ test('filter reappears in dropdown after clearing with X 
icon', async () => {
   });
 });
 
+const setupAnchorMocks = (
+  nativeFilters: Record<string, unknown>,
+  anchor = 'TAB-abc',
+  tabsOverride?: {
+    all_tabs: Record<string, string>;
+    tab_tree: { title: string; value: string }[];
+  },
+) => {
+  const payloadWithAnchor = {
+    ...generateMockPayload(true),
+    extra: { dashboard: { anchor } },
+  };
+
+  const defaultTabs = {
+    all_tabs: { [anchor]: `Tab ${anchor}` },
+    tab_tree: [{ title: `Tab ${anchor}`, value: anchor }],
+  };
+  const tabs = tabsOverride ?? defaultTabs;
+
+  // Clear call history so waitFor assertions don't match calls from prior 
tests.
+  fetchMock.callHistory.clear();
+
+  // Only replace the named routes that need anchor-specific overrides;
+  // unnamed related-endpoint routes (owners, database, etc.) stay intact.
+  fetchMock.removeRoute(FETCH_DASHBOARD_ENDPOINT);
+  fetchMock.removeRoute(FETCH_CHART_ENDPOINT);
+  fetchMock.removeRoute(tabsEndpoint);
+
+  fetchMock.get(
+    FETCH_DASHBOARD_ENDPOINT,
+    { result: payloadWithAnchor },
+    { name: FETCH_DASHBOARD_ENDPOINT },
+  );
+  fetchMock.get(
+    FETCH_CHART_ENDPOINT,
+    { result: generateMockPayload(false) },
+    { name: FETCH_CHART_ENDPOINT },
+  );
+  fetchMock.get(
+    tabsEndpoint,
+    {
+      result: {
+        ...tabs,
+        native_filters: nativeFilters,
+      },
+    },
+    { name: tabsEndpoint },
+  );
+};
+
+const restoreAnchorMocks = () => {
+  fetchMock.removeRoute(FETCH_DASHBOARD_ENDPOINT);
+  fetchMock.get(
+    FETCH_DASHBOARD_ENDPOINT,
+    { result: generateMockPayload(true) },
+    { name: FETCH_DASHBOARD_ENDPOINT },
+  );
+  fetchMock.removeRoute(FETCH_CHART_ENDPOINT);
+  fetchMock.get(
+    FETCH_CHART_ENDPOINT,
+    { result: generateMockPayload(false) },
+    { name: FETCH_CHART_ENDPOINT },
+  );
+  fetchMock.removeRoute(tabsEndpoint);
+  fetchMock.get(
+    tabsEndpoint,
+    { result: { all_tabs: {}, tab_tree: [] } },
+    { name: tabsEndpoint },
+  );
+};
+
+test('no error toast when anchor tab has no scoped native filters', async () 
=> {
+  setupAnchorMocks({
+    all: [
+      {
+        id: 'NATIVE_FILTER-1',
+        name: 'Filter 1',
+        filterType: 'filter_select',
+        targets: [{ column: { name: 'col' } }],
+        adhoc_filters: [],
+      },
+    ],
+  });
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+    await screen.findByText(/test dashboard/i);
+
+    await waitFor(() => {
+      expect(
+        fetchMock.callHistory
+          .calls()
+          .some(c => c.url.includes('/dashboard/1/tabs')),
+      ).toBe(true);
+    });
+
+    const toasts = (store.getState() as Record<string, unknown>)
+      .messageToasts as { text: string }[];
+    expect(
+      toasts.some(
+        (toast: { text: string }) =>
+          toast.text === 'There was an error retrieving dashboard tabs.',
+      ),
+    ).toBe(false);
+  } finally {
+    restoreAnchorMocks();
+  }
+});
+
+test('no error toast when anchor tab set and dashboard has zero native 
filters', async () => {
+  setupAnchorMocks({});
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+    await screen.findByText(/test dashboard/i);
+
+    await waitFor(() => {
+      expect(
+        fetchMock.callHistory
+          .calls()
+          .some(c => c.url.includes('/dashboard/1/tabs')),
+      ).toBe(true);
+    });
+
+    const toasts = (store.getState() as Record<string, unknown>)
+      .messageToasts as { text: string }[];
+    expect(
+      toasts.some(
+        (toast: { text: string }) =>
+          toast.text === 'There was an error retrieving dashboard tabs.',
+      ),
+    ).toBe(false);
+  } finally {
+    restoreAnchorMocks();
+  }
+});
+
+test('stale JSON array anchor is cleared without crash or toast', async () => {
+  const staleAnchor = JSON.stringify(['TAB-abc', 'TAB-missing']);
+  setupAnchorMocks(
+    {
+      all: [
+        {
+          id: 'NATIVE_FILTER-1',
+          name: 'Filter 1',
+          filterType: 'filter_select',
+          targets: [{ column: { name: 'col' } }],
+          adhoc_filters: [],
+        },
+      ],
+    },
+    staleAnchor,
+    {
+      all_tabs: { 'TAB-abc': 'Tab ABC' },
+      tab_tree: [{ title: 'Tab ABC', value: 'TAB-abc' }],
+    },
+  );
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+    await screen.findByText(/test dashboard/i);
+
+    // Wait for the tabs useEffect to process the stale anchor
+    await waitFor(() => {
+      expect(
+        fetchMock.callHistory
+          .calls()
+          .some(c => c.url.includes('/dashboard/1/tabs')),
+      ).toBe(true);
+    });
+
+    // No error toast dispatched (the .then() handler ran without crashing)
+    const toasts = (store.getState() as Record<string, unknown>)
+      .messageToasts as { text: string }[];
+    expect(
+      toasts.some(
+        (toast: { text: string }) =>
+          toast.text === 'There was an error retrieving dashboard tabs.',
+      ),
+    ).toBe(false);
+
+    // Verify anchor was cleared at the payload level: trigger save and
+    // inspect the PUT body to confirm extra.dashboard.anchor is undefined
+    const updateEndpoint = 'glob:*/api/v1/report/1';
+    fetchMock.put(
+      updateEndpoint,
+      { id: 1, result: {} },
+      { name: 'put-report-1' },
+    );
+
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    expect(saveButton).not.toBeDisabled();
+    userEvent.click(saveButton);
+
+    await waitFor(() => {
+      const putCalls = fetchMock.callHistory
+        .calls()
+        .filter(
+          c => c.url.includes('/api/v1/report/') && c.options?.method === 
'put',
+        );
+      expect(putCalls).toHaveLength(1);
+    });
+
+    const putCall = fetchMock.callHistory
+      .calls()
+      .find(
+        c => c.url.includes('/api/v1/report/') && c.options?.method === 'put',
+      );
+    const body = JSON.parse(putCall!.options?.body as string);
+    expect(body.extra.dashboard.anchor).toBeUndefined();
+  } finally {
+    fetchMock.removeRoute('put-report-1');
+    restoreAnchorMocks();
+  }
+});
+
+test('tabs API failure shows danger toast via Redux store', async () => {
+  fetchMock.removeRoute(tabsEndpoint);
+  fetchMock.get(tabsEndpoint, 500, { name: tabsEndpoint });
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+
+    await waitFor(() => {
+      const toasts = (store.getState() as Record<string, unknown>)
+        .messageToasts as { text: string }[];
+      expect(
+        toasts.some(
+          (toast: { text: string }) =>
+            toast.text === 'There was an error retrieving dashboard tabs.',
+        ),
+      ).toBe(true);
+    });
+  } finally {
+    fetchMock.removeRoute(tabsEndpoint);
+    fetchMock.get(
+      tabsEndpoint,
+      { result: { all_tabs: {}, tab_tree: [] } },
+      { name: tabsEndpoint },
+    );
+  }
+});
+
+test('null all_tabs does not crash or show error toast', async () => {
+  setupAnchorMocks({
+    all: [
+      {
+        id: 'NATIVE_FILTER-1',
+        name: 'Filter 1',
+        filterType: 'filter_select',
+        targets: [{ column: { name: 'col' } }],
+        adhoc_filters: [],
+      },
+    ],
+  });
+
+  // Override tabs endpoint with null all_tabs
+  fetchMock.removeRoute(tabsEndpoint);
+  fetchMock.get(
+    tabsEndpoint,
+    {
+      result: {
+        all_tabs: null,
+        tab_tree: [{ title: 'Tab ABC', value: 'TAB-abc' }],
+        native_filters: {
+          all: [
+            {
+              id: 'NATIVE_FILTER-1',
+              name: 'Filter 1',
+              filterType: 'filter_select',
+              targets: [{ column: { name: 'col' } }],
+              adhoc_filters: [],
+            },
+          ],
+        },
+      },
+    },
+    { name: tabsEndpoint },
+  );
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+    await screen.findByText(/test dashboard/i);
+
+    // Wait for tabs useEffect to complete
+    await waitFor(() => {
+      expect(
+        fetchMock.callHistory
+          .calls()
+          .some(c => c.url.includes('/dashboard/1/tabs')),
+      ).toBe(true);
+    });
+
+    // No error toast dispatched
+    const toasts = (store.getState() as Record<string, unknown>)
+      .messageToasts as { text: string }[];
+    expect(
+      toasts.some(
+        (toast: { text: string }) =>
+          toast.text === 'There was an error retrieving dashboard tabs.',
+      ),
+    ).toBe(false);
+
+    // Component remains interactive
+    expect(
+      screen.getByRole('combobox', { name: /select filter/i }),
+    ).toBeInTheDocument();
+  } finally {
+    restoreAnchorMocks();
+  }
+});
+
+test('missing native_filters in tabs response does not crash or show error 
toast', async () => {
+  setupAnchorMocks({});
+
+  // Override tabs endpoint with no native_filters key
+  fetchMock.removeRoute(tabsEndpoint);
+  fetchMock.get(
+    tabsEndpoint,
+    {
+      result: {
+        all_tabs: { 'TAB-abc': 'Tab ABC' },
+        tab_tree: [{ title: 'Tab ABC', value: 'TAB-abc' }],
+      },
+    },
+    { name: tabsEndpoint },
+  );
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+    await screen.findByText(/test dashboard/i);
+
+    // Wait for tabs useEffect to complete
+    await waitFor(() => {
+      expect(
+        fetchMock.callHistory
+          .calls()
+          .some(c => c.url.includes('/dashboard/1/tabs')),
+      ).toBe(true);
+    });
+
+    // No error toast dispatched
+    const toasts = (store.getState() as Record<string, unknown>)
+      .messageToasts as { text: string }[];
+    expect(
+      toasts.some(
+        (toast: { text: string }) =>
+          toast.text === 'There was an error retrieving dashboard tabs.',
+      ),
+    ).toBe(false);
+  } finally {
+    restoreAnchorMocks();
+  }
+});
+
+test('anchor tab with scoped filters loads filter options correctly', async () 
=> {
+  // Use JSON-parseable non-array anchor to exercise the scoped filter
+  // code path at line 1108 (JSON.parse('42') → 42, not an array)
+  setupAnchorMocks(
+    {
+      all: [
+        {
+          id: 'NATIVE_FILTER-1',
+          name: 'Global Filter',
+          filterType: 'filter_select',
+          targets: [{ column: { name: 'col' } }],
+          adhoc_filters: [],
+        },
+      ],
+      '42': [
+        {
+          id: 'NATIVE_FILTER-2',
+          name: 'Tab Scoped Filter',
+          filterType: 'filter_select',
+          targets: [{ column: { name: 'col2' } }],
+          adhoc_filters: [],
+        },
+      ],
+    },
+    '42',
+  );
+
+  const store = createStore({}, reducerIndex);
+
+  try {
+    render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+      store,
+    });
+
+    userEvent.click(screen.getByTestId('contents-panel'));
+    await screen.findByText(/test dashboard/i);
+
+    const filterDropdown = screen.getByRole('combobox', {
+      name: /select filter/i,
+    });
+    userEvent.click(filterDropdown);
+
+    const filterOption = await screen.findByRole('option', {
+      name: /Tab Scoped Filter/,
+    });
+    expect(filterOption).toBeInTheDocument();
+
+    const toasts = (store.getState() as Record<string, unknown>)
+      .messageToasts as { text: string }[];
+    expect(
+      toasts.some(
+        (toast: { text: string }) =>
+          toast.text === 'There was an error retrieving dashboard tabs.',
+      ),
+    ).toBe(false);
+  } finally {
+    restoreAnchorMocks();
+  }
+});
+
 test('edit mode shows friendly filter names instead of raw IDs', async () => {
   const props = generateMockedProps(true, true);
   const editProps = {
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx 
b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index 87ffbf0f40..cf91d3d038 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -87,6 +87,7 @@ import {
   ContentType,
   ExtraNativeFilter,
   NativeFilterObject,
+  DashboardTabsResponse,
 } from 'src/features/alerts/types';
 import { StatusMessage } from 'src/filters/components/common';
 import { useSelector } from 'react-redux';
@@ -526,7 +527,9 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
       label: string;
     }[]
   >([]);
-  const [tabNativeFilters, setTabNativeFilters] = useState<object>({});
+  const [tabNativeFilters, setTabNativeFilters] = useState<
+    Partial<Record<string, NativeFilterObject[]>>
+  >({});
   const [nativeFilterData, setNativeFilterData] = 
useState<ExtraNativeFilter[]>(
     [
       {
@@ -679,9 +682,9 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
   const fetchDashboardFilterValues = async (
     dashboardId: number | string | undefined,
     columnName: string,
-    datasetId: number | string,
+    datasetId: number | string | null,
     vizType = 'filter_select',
-    adhocFilters = [],
+    adhocFilters: any[] = [],
   ) => {
     if (vizType === 'filter_time') {
       return;
@@ -742,7 +745,7 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
     nativeFilterData.map(nativeFilter => {
       if (!nativeFilter.nativeFilterId) return;
       const filter = nativeFilters.filter(
-        (f: any) => f.id === nativeFilter.nativeFilterId,
+        f => f.id === nativeFilter.nativeFilterId,
       )[0];
 
       const { datasetId } = filter.targets[0];
@@ -1079,10 +1082,8 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
             tab_tree: tabTree,
             all_tabs: allTabs,
             native_filters: nativeFilters,
-          } = response.json.result;
-          const allTabsWithOrder = tabTree.map(
-            (tab: { value: string }) => tab.value,
-          );
+          }: DashboardTabsResponse = response.json.result;
+          const allTabsWithOrder = tabTree.map(tab => tab.value);
 
           // Only show all tabs when there are more than one tab
           if (allTabsWithOrder.length > 1) {
@@ -1094,14 +1095,14 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
           }
 
           setTabOptions(tabTree);
-          setTabNativeFilters(nativeFilters);
+          setTabNativeFilters(nativeFilters ?? {});
 
-          if (isEditMode && nativeFilters.all) {
+          if (isEditMode && nativeFilters?.all) {
             // update options for all filters
             addNativeFilterOptions(nativeFilters.all);
             // Also set the available filter options for the add button
             setNativeFilterOptions(
-              nativeFilters.all.map((filter: any) => ({
+              nativeFilters.all.map(filter => ({
                 value: filter.id,
                 label: filter.name,
               })),
@@ -1113,8 +1114,10 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
               const parsedAnchor = JSON.parse(anchor);
               if (!Array.isArray(parsedAnchor)) {
                 // only show filters scoped to anchor
+                const anchorFilters: NativeFilterObject[] =
+                  nativeFilters?.[anchor] ?? [];
                 setNativeFilterOptions(
-                  nativeFilters[anchor].map((filter: any) => ({
+                  anchorFilters.map(filter => ({
                     value: filter.id,
                     label: filter.name,
                   })),
@@ -1122,7 +1125,8 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
               }
               if (Array.isArray(parsedAnchor)) {
                 // Check if all elements in parsedAnchor list are in allTabs
-                const isValidSubset = parsedAnchor.every(tab => tab in 
allTabs);
+                const isValidSubset =
+                  allTabs && parsedAnchor.every(tab => tab in allTabs);
                 if (!isValidSubset) {
                   updateAnchorState(undefined);
                 }
@@ -1130,13 +1134,13 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
                 throw new Error('Parsed value is not an array');
               }
             } catch (error) {
-              if (!(anchor in allTabs)) {
+              if (!allTabs || !(anchor in allTabs)) {
                 updateAnchorState(undefined);
               }
             }
-          } else if (nativeFilters.all) {
+          } else if (nativeFilters?.all) {
             setNativeFilterOptions(
-              nativeFilters.all.map((filter: any) => ({
+              nativeFilters.all.map(filter => ({
                 value: filter.id,
                 label: filter.name,
               })),
@@ -1438,8 +1442,8 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
       return;
 
     // find specific filter tied to the selected filter
-    const filters = Object.values(tabNativeFilters).flat();
-    const filter = filters.filter((f: any) => f.id === nativeFilterId)[0];
+    const filters = Object.values(tabNativeFilters).flatMap(arr => arr ?? []);
+    const filter = filters.filter(f => f.id === nativeFilterId)[0];
 
     const { filterType, adhoc_filters: adhocFilters } = filter;
     const filterAlreadyExist = nativeFilterData.some(
diff --git a/superset-frontend/src/features/alerts/types.ts 
b/superset-frontend/src/features/alerts/types.ts
index c8bb02c74d..7a518f5394 100644
--- a/superset-frontend/src/features/alerts/types.ts
+++ b/superset-frontend/src/features/alerts/types.ts
@@ -231,6 +231,7 @@ export type NativeFilterObject = {
     rootPath: string[];
   };
   tabsInScope: string[];
+  adhoc_filters: any[];
   targets: Array<{
     column: {
       name: string;
@@ -239,3 +240,9 @@ export type NativeFilterObject = {
   }>;
   type: string;
 };
+
+export type DashboardTabsResponse = {
+  tab_tree: TabNode[];
+  all_tabs: Record<string, string>;
+  native_filters: Partial<Record<string, NativeFilterObject[]>>;
+};

Reply via email to