Skip to content

Commit 3d3a813

Browse files
feat(workflow_engine): Add useSetAutomaticName hook for detector forms (#101221)
Introduces a new useSetAutomaticName hook that enables automatic name generation for detector forms based on field values. This hook uses mobx autorun to reactively watch form fields and automatically set the detector name until the user manually edits it. Key changes: - Created useSetAutomaticName hook in detector forms common directory - Extended DetectorFormContext to track hasSetDetectorName state and optionally include the detector being edited - Updated DetectorBaseFields to set hasSetDetectorName when user edits the name field - Integrated automatic naming into uptime detector form to generate names from URL field (e.g., "Uptime check for example.com/path") - Uses URL.parse() for safe URL parsing without try-catch The hook automatically stops name generation when: - User manually edits the detector name - Editing an existing detector (detector prop passed to context) This pattern can be easily extended to other detector types by calling useSetAutomaticName with a custom name generation function. Fixes [NEW-556: Entering a URL should auto-fill the title of the detector](https://linear.app/getsentry/issue/NEW-556/entering-a-url-should-auto-fill-the-title-of-the-detector)
1 parent f426c43 commit 3d3a813

File tree

7 files changed

+255
-5
lines changed

7 files changed

+255
-5
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {ErrorDetectorFixture} from 'sentry-fixture/detectors';
2+
import {OrganizationFixture} from 'sentry-fixture/organization';
3+
import {ProjectFixture} from 'sentry-fixture/project';
4+
5+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
6+
7+
import TextField from 'sentry/components/forms/fields/textField';
8+
import ProjectsStore from 'sentry/stores/projectsStore';
9+
import type {Detector} from 'sentry/types/workflowEngine/detectors';
10+
import {useSetAutomaticName} from 'sentry/views/detectors/components/forms/common/useSetAutomaticName';
11+
import {DetectorFormProvider} from 'sentry/views/detectors/components/forms/context';
12+
import {NewDetectorLayout} from 'sentry/views/detectors/components/forms/newDetectorLayout';
13+
14+
// Test component that uses the hook
15+
function TestDetectorForm() {
16+
useSetAutomaticName(form => {
17+
const testField = form.getValue('testField');
18+
if (typeof testField !== 'string' || !testField) {
19+
return null;
20+
}
21+
return `Monitor for ${testField}`;
22+
});
23+
24+
return (
25+
<div>
26+
<TextField name="testField" label="Test Field" />
27+
</div>
28+
);
29+
}
30+
31+
describe('useSetAutomaticName', () => {
32+
const organization = OrganizationFixture();
33+
const project = ProjectFixture();
34+
35+
const renderDetectorForm = (detector?: Detector, initialFormData = {}) => {
36+
return render(
37+
<DetectorFormProvider detectorType="error" project={project} detector={detector}>
38+
<NewDetectorLayout
39+
detectorType="error"
40+
formDataToEndpointPayload={data => data as any}
41+
initialFormData={initialFormData}
42+
>
43+
<TestDetectorForm />
44+
</NewDetectorLayout>
45+
</DetectorFormProvider>,
46+
{organization}
47+
);
48+
};
49+
50+
beforeEach(() => {
51+
ProjectsStore.loadInitialData([project]);
52+
});
53+
54+
it('automatically generates and updates name from field value', async () => {
55+
renderDetectorForm();
56+
57+
await screen.findByText('New Monitor');
58+
59+
const testField = screen.getByRole('textbox', {name: 'Test Field'});
60+
61+
// Type first value
62+
await userEvent.type(testField, 'service-1');
63+
await screen.findByText('Monitor for service-1');
64+
65+
// Change the value
66+
await userEvent.clear(testField);
67+
await userEvent.type(testField, 'service-2');
68+
await screen.findByText('Monitor for service-2');
69+
});
70+
71+
it('stops auto-generating after user manually edits name', async () => {
72+
renderDetectorForm();
73+
74+
await screen.findByText('New Monitor');
75+
76+
// Type into test field - name should auto-generate
77+
const testField = screen.getByRole('textbox', {name: 'Test Field'});
78+
await userEvent.type(testField, 'my-service');
79+
80+
const nameField = await screen.findByText('Monitor for my-service');
81+
82+
// Manually edit the name
83+
await userEvent.click(nameField);
84+
const nameInput = screen.getByRole('textbox', {name: 'Monitor Name'});
85+
await userEvent.clear(nameInput);
86+
await userEvent.type(nameInput, 'Custom Monitor Name{Enter}');
87+
88+
await screen.findByText('Custom Monitor Name');
89+
90+
// Change test field - name should NOT update
91+
await userEvent.clear(testField);
92+
await userEvent.type(testField, 'different-service');
93+
94+
// Verify name didn't change
95+
expect(screen.getByText('Custom Monitor Name')).toBeInTheDocument();
96+
expect(screen.queryByText('Monitor for different-service')).not.toBeInTheDocument();
97+
});
98+
99+
it('does not auto-generate name when editing existing detector', async () => {
100+
const detector = ErrorDetectorFixture({
101+
name: 'Existing Monitor',
102+
projectId: project.id,
103+
});
104+
105+
renderDetectorForm(detector, {name: detector.name});
106+
107+
await screen.findByText(detector.name);
108+
109+
// Type into test field - name should NOT auto-generate
110+
const testField = screen.getByRole('textbox', {name: 'Test Field'});
111+
await userEvent.type(testField, 'my-service');
112+
113+
// Verify name didn't change
114+
expect(screen.getByText(detector.name)).toBeInTheDocument();
115+
expect(screen.queryByText('Monitor for my-service')).not.toBeInTheDocument();
116+
});
117+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {useContext, useEffect} from 'react';
2+
import {autorun} from 'mobx';
3+
4+
import FormContext from 'sentry/components/forms/formContext';
5+
import type FormModel from 'sentry/components/forms/model';
6+
import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context';
7+
8+
/**
9+
* Hook to automatically set the detector name based on form values.
10+
*
11+
* The provided function is called with a mobx autorun, so any access to
12+
* getters on the prvided form model will automatically act reactively.
13+
*
14+
* @example
15+
* ```tsx
16+
* useSetAutomaticName((form) => {
17+
* const metricType = form.getValue('metricType');
18+
* const interval = form.getValue('interval');
19+
*
20+
* if (!metricType || !interval) {
21+
* return null;
22+
* }
23+
*
24+
* return t('Check %s every %s', metricType, getDuration(interval));
25+
* });
26+
* ```
27+
*/
28+
export function useSetAutomaticName(getNameFn: (form: FormModel) => string | null) {
29+
const {form} = useContext(FormContext);
30+
const {hasSetDetectorName, detector} = useDetectorFormContext();
31+
32+
useEffect(() => {
33+
if (form === undefined || hasSetDetectorName) {
34+
return () => {};
35+
}
36+
37+
// Don't auto-generate name if we're editing an existing detector
38+
if (detector) {
39+
return () => {};
40+
}
41+
42+
return autorun(() => {
43+
const generatedName = getNameFn(form);
44+
if (generatedName) {
45+
form.setValue('name', generatedName);
46+
}
47+
});
48+
}, [form, hasSetDetectorName, getNameFn, detector]);
49+
}

static/app/views/detectors/components/forms/context.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,39 @@
1-
import {createContext, useContext} from 'react';
1+
import {createContext, useContext, useState} from 'react';
22

33
import type {Project} from 'sentry/types/project';
4-
import type {DetectorType} from 'sentry/types/workflowEngine/detectors';
4+
import type {Detector, DetectorType} from 'sentry/types/workflowEngine/detectors';
55

66
type DetectorFormContextType = {
77
detectorType: DetectorType;
8+
/**
9+
* Tracks whether the user has manually set the detector name.
10+
* Used by useSetAutomaticName to disable automatic name generation.
11+
*/
12+
hasSetDetectorName: boolean;
813
project: Project;
14+
setHasSetDetectorName: (value: boolean) => void;
15+
detector?: Detector;
916
};
1017

1118
const DetectorFormContext = createContext<DetectorFormContextType | null>(null);
1219

1320
export function DetectorFormProvider({
1421
detectorType,
1522
project,
23+
detector,
1624
children,
1725
}: {
1826
children: React.ReactNode;
1927
detectorType: DetectorType;
2028
project: Project;
29+
detector?: Detector;
2130
}) {
31+
const [hasSetDetectorName, setHasSetDetectorName] = useState(false);
32+
2233
return (
23-
<DetectorFormContext.Provider value={{detectorType, project}}>
34+
<DetectorFormContext.Provider
35+
value={{detectorType, project, hasSetDetectorName, setHasSetDetectorName, detector}}
36+
>
2437
{children}
2538
</DetectorFormContext.Provider>
2639
);

static/app/views/detectors/components/forms/detectorBaseFields.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/co
1313
import {useCanEditDetector} from 'sentry/views/detectors/utils/useCanEditDetector';
1414

1515
export function DetectorBaseFields() {
16+
const {setHasSetDetectorName} = useDetectorFormContext();
17+
1618
return (
1719
<Flex gap="md" direction="column">
1820
<Layout.Title>
@@ -27,6 +29,7 @@ export function DetectorBaseFields() {
2729
value: newValue,
2830
},
2931
});
32+
setHasSetDetectorName(true);
3033
}}
3134
errorMessage={t('Please set a title')}
3235
placeholder={t('New Monitor')}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import styled from '@emotion/styled';
22

3+
import {t} from 'sentry/locale';
34
import type {UptimeDetector} from 'sentry/types/workflowEngine/detectors';
45
import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection';
56
import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection';
7+
import {useSetAutomaticName} from 'sentry/views/detectors/components/forms/common/useSetAutomaticName';
68
import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDetectorLayout';
79
import {NewDetectorLayout} from 'sentry/views/detectors/components/forms/newDetectorLayout';
810
import {UptimeDetectorFormDetectSection} from 'sentry/views/detectors/components/forms/uptime/detect';
@@ -14,6 +16,24 @@ import {UptimeRegionWarning} from 'sentry/views/detectors/components/forms/uptim
1416
import {UptimeDetectorResolveSection} from 'sentry/views/detectors/components/forms/uptime/resolve';
1517

1618
function UptimeDetectorForm() {
19+
useSetAutomaticName(form => {
20+
const url = form.getValue('url');
21+
22+
if (typeof url !== 'string') {
23+
return null;
24+
}
25+
26+
const parsedUrl = URL.parse(url);
27+
if (!parsedUrl) {
28+
return null;
29+
}
30+
31+
const path = parsedUrl.pathname === '/' ? '' : parsedUrl.pathname;
32+
const urlName = `${parsedUrl.hostname}${path}`.replace(/\/$/, '');
33+
34+
return t('Uptime check for %s', urlName);
35+
});
36+
1737
return (
1838
<FormStack>
1939
<UptimeRegionWarning />

static/app/views/detectors/edit.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export default function DetectorEdit() {
4141
}
4242

4343
return (
44-
<DetectorFormProvider detectorType={detector.type} project={project}>
44+
<DetectorFormProvider
45+
detectorType={detector.type}
46+
project={project}
47+
detector={detector}
48+
>
4549
<EditExistingDetectorForm detector={detector} />
4650
</DetectorFormProvider>
4751
);

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,56 @@ describe('DetectorEdit', () => {
461461
traceSampling: undefined,
462462
url: 'https://uptime-custom.example.com',
463463
},
464-
name: 'New Monitor',
464+
name: 'Uptime check for uptime-custom.example.com',
465465
projectId: '2',
466466
type: 'uptime_domain_failure',
467467
}),
468468
})
469469
);
470470
});
471+
472+
it('automatically sets monitor name from URL and stops after manual edit', async () => {
473+
render(<DetectorNewSettings />, {
474+
organization,
475+
initialRouterConfig: uptimeRouterConfig,
476+
});
477+
478+
const nameField = await screen.findByText('New Monitor');
479+
480+
// Type a simple hostname
481+
await userEvent.type(
482+
screen.getByRole('textbox', {name: 'URL'}),
483+
'https://my-cool-site.com/'
484+
);
485+
486+
await screen.findByText('Uptime check for my-cool-site.com');
487+
488+
// Clear and type a URL with a path - name should update
489+
let urlInput = screen.getByRole('textbox', {name: 'URL'});
490+
await userEvent.clear(urlInput);
491+
await userEvent.type(urlInput, 'https://example.com/with-path');
492+
493+
// Name was updated with auto-generated name
494+
expect(nameField).toHaveTextContent('Uptime check for example.com/with-path');
495+
496+
// Manually edit the name
497+
await userEvent.click(nameField);
498+
const nameInput = screen.getByRole('textbox', {name: 'Monitor Name'});
499+
await userEvent.clear(nameInput);
500+
await userEvent.type(nameInput, 'My Custom Name{Enter}');
501+
502+
await screen.findByText('My Custom Name');
503+
504+
// Change the URL - name should NOT update anymore
505+
urlInput = screen.getByRole('textbox', {name: 'URL'});
506+
await userEvent.clear(urlInput);
507+
await userEvent.type(urlInput, 'https://different-site.com');
508+
509+
// Verify the name didn't change
510+
expect(screen.getByText('My Custom Name')).toBeInTheDocument();
511+
expect(
512+
screen.queryByText('Uptime check for different-site.com')
513+
).not.toBeInTheDocument();
514+
});
471515
});
472516
});

0 commit comments

Comments
 (0)