Compare commits

...
Author SHA1 Message Date
sadpandajoeandClaude Opus 4.8 49df51eb26 fix(reports): resolve edit-mode report by creationMethod scope
The ReportModal selected the existing report to edit by keying off `dashboardId` first, while the save payload already keyed off `creationMethod`. In Explore, a chart opened from a dashboard carries both a `dashboardId` context prop and a chart-scoped `creationMethod`, so the modal could load an unrelated dashboard-scoped report into edit mode; saving it then sent a chart-only payload to the dashboard report's id, targeting the wrong resource.

Resolve the existing report using the same `creationMethod`-based scope and id the save payload uses, so a chart Explore context never edits a dashboard-scoped report, and vice versa. Add a regression test covering the chart-opened-from-dashboard case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 22:40:47 +00:00
Joe Li bed31eadee Merge branch 'master' into fix-report-creation-chart-dashboard-payload 2026-08-18 11:32:37 -07:00
Joe Li 44ee39152d Merge branch 'master' into fix-report-creation-chart-dashboard-payload 2026-08-14 15:38:25 -07:00
Joe Li 9f3fca1e67 Merge branch 'master' into fix-report-creation-chart-dashboard-payload 2026-08-13 22:34:09 -07:00
sadpandajoeandClaude Opus 4.8 2dd0786634 fix(reports): send only chart or dashboard when creating a report, not both
Creating a report or alert from a chart opened in Explore from within a
dashboard failed with HTTP 422 `{"message": {"chart": "Choose a chart or
dashboard not both"}}`. The report modal built its payload with both
`chart` and `dashboard` set unconditionally, so Explore's dashboard
context (used for the report fetch/edit lookup) leaked into a chart-scoped
report payload and the backend rejected it. Opening the same chart from
the Charts list, where there is no dashboard context, worked.

A report belongs to either a chart or a dashboard, never both. Select the
owning entity by creation method: chart-scoped reports send only `chart`,
dashboard-scoped reports send only `dashboard`. This flows through both
the create (subscribe) and edit paths, which share the payload builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 22:33:41 +00:00
2 changed files with 106 additions and 9 deletions
@@ -170,10 +170,54 @@ test('creates a new email report via modal Add button', async () => {
// creation_method, editors, and recipients are set server-side; not in the client payload
expect(body.creation_method).toBeUndefined();
expect(body.recipients).toBeUndefined();
// Dashboard-scoped report (creationMethod='dashboards'): send `dashboard`,
// never `chart`, so the backend does not reject "both".
expect(body.dashboard).toBe(1);
expect(body.chart).toBeUndefined();
fetchMock.removeRoute('post-subscribe');
});
test('sends only chart, not dashboard, when creating a chart report from a dashboard context', async () => {
// Regression: opening a chart in Explore *from a dashboard* passes the modal a
// `dashboardId` context prop while the report stays chart-scoped
// (creationMethod='charts'). The payload must carry only `chart`; sending both
// `chart` and `dashboard` makes the backend reject with a 422
// "Choose a chart or dashboard not both".
fetchMock.post(
'glob:*/api/v1/report/subscribe',
{ id: 1, result: {} },
{ name: 'post-subscribe-chart' },
);
// Routes persist across tests in this file, so always remove this one — even if
// an assertion below throws — to avoid shadowing later tests' report routes.
try {
const chartFromDashboardProps = {
...defaultProps,
creationMethod: 'charts' as const,
dashboardId: 7,
chart: { id: 119, sliceFormData: { viz_type: VizType.Line } },
};
render(<ReportModal {...chartFromDashboardProps} />, { useRedux: true });
const addButton = screen.getByRole('button', { name: /add/i });
await waitFor(() => userEvent.click(addButton));
await waitFor(() => {
const postCalls = fetchMock.callHistory.calls('post-subscribe-chart');
expect(postCalls).toHaveLength(1);
});
const postCalls = fetchMock.callHistory.calls('post-subscribe-chart');
const body = JSON.parse(postCalls[0].options.body as string);
expect(body.chart).toBe(119);
expect(body.dashboard).toBeUndefined();
} finally {
fetchMock.removeRoute('post-subscribe-chart');
}
});
test('text-based chart hides screenshot width and shows message content', () => {
// Table is text-based: should show message content but hide custom width
const textChartProps = {
@@ -288,6 +332,51 @@ test('renders edit mode when report exists in store', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
test('resolves the chart-scoped report, not the dashboard-scoped one, in a dashboard context', () => {
// Regression: opening a chart in Explore *from a dashboard* passes the modal
// both a `dashboardId` context prop and a chart-scoped `creationMethod`.
// Edit-mode resolution must follow the same scope as the save payload
// (`creationMethod`); keying off `dashboardId` first loads the unrelated
// dashboard-scoped report, so a save then targets the wrong report id.
const dashboardReport = {
id: 42,
name: 'Existing Dashboard Report',
creation_method: 'dashboards',
dashboard: 7,
};
const chartReport = {
id: 77,
name: 'Existing Chart Report',
creation_method: 'charts',
chart: 119,
};
const store = createStore(
{
reports: {
dashboards: { 7: dashboardReport },
charts: { 119: chartReport },
},
},
reducerIndex,
);
const chartFromDashboardProps = {
...defaultProps,
creationMethod: 'charts' as const,
dashboardId: 7,
chart: { id: 119, sliceFormData: { viz_type: VizType.Line } },
};
render(<ReportModal {...chartFromDashboardProps} />, {
useRedux: true,
store,
});
// The modal must load the chart's own report, not the dashboard's.
const reportNameTextbox = screen.getByTestId('report-name-test');
expect(reportNameTextbox).toHaveDisplayValue('Existing Chart Report');
expect(reportNameTextbox).not.toHaveDisplayValue('Existing Dashboard Report');
});
test('edit mode dispatches editReport via PUT on save', async () => {
const existingReport = {
id: 42,
@@ -165,13 +165,16 @@ function ReportModal({
const dispatch = useDispatch();
// Report fetch logic
const report = useSelector<any, ReportObject>(state => {
const resourceType = dashboardId
? CreationMethod.Dashboards
: CreationMethod.Charts;
return (
reportSelector(state, resourceType, dashboardId || chart?.id) ||
EMPTY_OBJECT
);
// Resolve the existing report with the same scope the save payload uses
// (`creationMethod`). Explore can carry a `dashboardId` context prop even for
// a chart-scoped report, so keying off `dashboardId` first would load an
// unrelated dashboard report and a later save would target the wrong id.
const isChartReport = creationMethod === CreationMethod.Charts;
const resourceType = isChartReport
? CreationMethod.Charts
: CreationMethod.Dashboards;
const resourceId = isChartReport ? chart?.id : dashboardId;
return reportSelector(state, resourceType, resourceId) || EMPTY_OBJECT;
});
const isEditMode = report && Object.keys(report).length;
@@ -189,8 +192,13 @@ function ReportModal({
active: true,
force_screenshot: false,
custom_width: currentReport.custom_width,
dashboard: dashboardId,
chart: chart?.id,
// A report belongs to either a chart or a dashboard, never both. Explore can
// carry dashboard context even for a chart-scoped report, so send only the
// entity that matches the creation method; a payload with both `chart` and
// `dashboard` is rejected by the backend with a 422 error.
...(creationMethod === CreationMethod.Charts
? { chart: chart?.id }
: { dashboard: dashboardId }),
name: currentReport.name,
description: currentReport.description,
crontab: currentReport.crontab,