Skip to content

Commit 62b2c2c

Browse files
committed
Add better handling for cron slug errors
1 parent 21ba982 commit 62b2c2c

File tree

5 files changed

+139
-2
lines changed

5 files changed

+139
-2
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {getDetectorResponseErrorMessage} from 'sentry/views/detectors/components/forms/common/getDetectorResponseErrorMessage';
2+
3+
describe('getDetectorResponseErrorMessage', () => {
4+
it('returns undefined for undefined', () => {
5+
expect(getDetectorResponseErrorMessage(undefined)).toBeUndefined();
6+
});
7+
8+
it('returns undefined for empty object', () => {
9+
expect(getDetectorResponseErrorMessage({})).toBeUndefined();
10+
});
11+
12+
it('handles {detail: "message"} shape', () => {
13+
expect(getDetectorResponseErrorMessage({detail: 'Something went wrong'})).toBe(
14+
'Something went wrong'
15+
);
16+
});
17+
18+
it('handles {field: ["message"]} shape', () => {
19+
expect(getDetectorResponseErrorMessage({name: ['Name is required']})).toBe(
20+
'Name is required'
21+
);
22+
});
23+
24+
it('handles {dataSources: {field: ["message"]}} shape', () => {
25+
expect(
26+
getDetectorResponseErrorMessage({dataSources: {query: ['Invalid query']}})
27+
).toBe('Invalid query');
28+
});
29+
30+
it('returns the first message when multiple fields have errors', () => {
31+
const result = getDetectorResponseErrorMessage({
32+
name: ['Name error'],
33+
query: ['Query error'],
34+
});
35+
expect(result).toBe('Name error');
36+
});
37+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Extracts the first human-readable error message from the detector API error response
3+
* so that we can surface it to the user in a toast.
4+
*
5+
* TODO: When migrating to the new form components, we should consider adding this
6+
* functionality generically
7+
*/
8+
export function getDetectorResponseErrorMessage(
9+
responseJSON: Record<string, unknown> | undefined
10+
): string | undefined {
11+
if (!responseJSON) {
12+
return undefined;
13+
}
14+
return findFirstMessage(responseJSON);
15+
}
16+
17+
function findFirstMessage(obj: Record<string, unknown>): string | undefined {
18+
for (const value of Object.values(obj)) {
19+
if (typeof value === 'string') {
20+
return value;
21+
}
22+
if (Array.isArray(value)) {
23+
if (typeof value[0] === 'string') {
24+
return value[0];
25+
}
26+
}
27+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
28+
const nested = findFirstMessage(value as Record<string, unknown>);
29+
if (nested) {
30+
return nested;
31+
}
32+
}
33+
}
34+
return undefined;
35+
}

static/app/views/detectors/components/forms/cron/index.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ const FORM_SECTIONS = [
3636
CronIssuePreview,
3737
AutomateSection,
3838
];
39+
/**
40+
* Maps API errors in the `dataSources` property to the correct form fields.
41+
* For Crons, `slug` may return error messages, and we want those to show
42+
* up under the `name` field.
43+
*
44+
* TODO: When converting to the new form components, find a less fragile way to do this.
45+
*/
46+
const mapCronDetectorFormErrors = (error: unknown) => {
47+
if (typeof error !== 'object' || error === null) {
48+
return error;
49+
}
50+
51+
if ('dataSources' in error) {
52+
if (typeof error.dataSources === 'object' && error.dataSources !== null) {
53+
if ('slug' in error.dataSources) {
54+
return {...error, name: error.dataSources.slug};
55+
}
56+
return {...error, ...error.dataSources};
57+
}
58+
}
59+
return error;
60+
};
3961

4062
function CronDetectorForm({detector}: {detector?: CronDetector}) {
4163
const dataSource = detector?.dataSources[0];
@@ -76,6 +98,7 @@ export function NewCronDetectorForm() {
7698
initialFormData={{
7799
scheduleType: CRON_DEFAULT_SCHEDULE_TYPE,
78100
}}
101+
mapFormErrors={mapCronDetectorFormErrors}
79102
disabledCreate={
80103
showingPlatformGuide
81104
? t(
@@ -95,6 +118,7 @@ export function EditExistingCronDetectorForm({detector}: {detector: CronDetector
95118
detector={detector}
96119
formDataToEndpointPayload={cronFormDataToEndpointPayload}
97120
savedDetectorToFormData={cronSavedDetectorToFormData}
121+
mapFormErrors={mapCronDetectorFormErrors}
98122
>
99123
<CronDetectorForm detector={detector} />
100124
</EditDetectorLayout>

static/app/views/detectors/hooks/useCreateDetectorFormSubmit.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import type {
99
DetectorType,
1010
} from 'sentry/types/workflowEngine/detectors';
1111
import {trackAnalytics} from 'sentry/utils/analytics';
12+
import {RequestError} from 'sentry/utils/requestError/requestError';
1213
import {useNavigate} from 'sentry/utils/useNavigate';
1314
import {useOrganization} from 'sentry/utils/useOrganization';
1415
import {getDetectorAnalyticsPayload} from 'sentry/views/detectors/components/forms/common/getDetectorAnalyticsPayload';
16+
import {getDetectorResponseErrorMessage} from 'sentry/views/detectors/components/forms/common/getDetectorResponseErrorMessage';
1517
import {useCreateDetector} from 'sentry/views/detectors/hooks';
1618
import {makeMonitorDetailsPathname} from 'sentry/views/detectors/pathnames';
1719

@@ -79,14 +81,18 @@ export function useCreateDetectorFormSubmit<
7981
}
8082

8183
onSubmitSuccess?.(resultDetector);
82-
} catch (error) {
84+
} catch (error: unknown) {
8385
trackAnalytics('monitor.created', {
8486
organization,
8587
detector_type: payload.type,
8688
success: false,
8789
});
8890

89-
addErrorMessage(t('Unable to create monitor'));
91+
addErrorMessage(
92+
(error instanceof RequestError
93+
? getDetectorResponseErrorMessage(error.responseJSON)
94+
: null) ?? t('Unable to create monitor')
95+
);
9096

9197
if (onError) {
9298
onError(error);

static/app/views/detectors/new-setting.spec.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from 'sentry-test/reactTestingLibrary';
1717
import {selectEvent} from 'sentry-test/selectEvent';
1818

19+
import * as indicators from 'sentry/actionCreators/indicator';
1920
import {OrganizationStore} from 'sentry/stores/organizationStore';
2021
import {ProjectsStore} from 'sentry/stores/projectsStore';
2122
import DetectorNewSettings from 'sentry/views/detectors/new-settings';
@@ -1241,5 +1242,39 @@ describe('DetectorEdit', () => {
12411242
})
12421243
);
12431244
});
1245+
1246+
it('displays slug errors on the name field and in a toast', async () => {
1247+
const mockAddErrorMessage = jest.spyOn(indicators, 'addErrorMessage');
1248+
MockApiClient.addMockResponse({
1249+
url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`,
1250+
method: 'POST',
1251+
statusCode: 400,
1252+
body: {
1253+
dataSources: {slug: ['The slug "new-test-cron-job" is already in use.']},
1254+
},
1255+
});
1256+
1257+
render(<DetectorNewSettings />, {
1258+
organization,
1259+
initialRouterConfig: cronRouterConfig,
1260+
});
1261+
1262+
const title = await screen.findByText('New Monitor');
1263+
await userEvent.click(title);
1264+
await userEvent.keyboard('new-test-cron-job{enter}');
1265+
1266+
await userEvent.click(screen.getByRole('button', {name: 'Create Monitor'}));
1267+
1268+
await waitFor(() => {
1269+
expect(mockAddErrorMessage).toHaveBeenCalledWith(
1270+
'The slug "new-test-cron-job" is already in use.'
1271+
);
1272+
});
1273+
1274+
// The slug error is mapped to the name field and shown inline
1275+
expect(
1276+
await screen.findByText('The slug "new-test-cron-job" is already in use.')
1277+
).toBeInTheDocument();
1278+
});
12441279
});
12451280
});

0 commit comments

Comments
 (0)