fix(alerts): fix error toast when editing report with saved tab selection (#38198)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-03-04 13:01:26 -08:00
committed by GitHub
parent a79dcbbb66
commit 939e4194c6
3 changed files with 493 additions and 21 deletions

View File

@@ -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 = {

View File

@@ -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(

View File

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