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[]>>;
+};