From 25da8ced4156357dc2b5c6f6f4c625f9518114ba Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Tue, 7 Apr 2026 15:34:56 -0700 Subject: [PATCH 1/2] fix(aci): Selected project state should react to form changes --- .../components/forms/automateSection.spec.tsx | 14 ++++--- .../components/forms/automateSection.tsx | 4 +- .../components/forms/common/projectField.tsx | 4 +- .../forms/common/useDetectorFormProject.tsx | 21 +++++++++++ .../forms/common/useSetAutomaticName.spec.tsx | 2 +- .../detectors/components/forms/context.tsx | 6 +-- .../forms/cron/cronIssuePreview.tsx | 4 +- .../components/forms/cron/index.spec.tsx | 2 +- .../components/forms/error/index.tsx | 1 + .../components/forms/metric/metric.spec.tsx | 6 ++- .../forms/metric/metricIssuePreview.tsx | 4 +- .../components/forms/newDetectorLayout.tsx | 21 +++++++++-- .../forms/uptime/uptimeIssuePreview.tsx | 4 +- static/app/views/detectors/edit.tsx | 14 +------ static/app/views/detectors/new-settings.tsx | 37 +++---------------- 15 files changed, 72 insertions(+), 72 deletions(-) create mode 100644 static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx diff --git a/static/app/views/detectors/components/forms/automateSection.spec.tsx b/static/app/views/detectors/components/forms/automateSection.spec.tsx index 801595242a33..5537ce38d7aa 100644 --- a/static/app/views/detectors/components/forms/automateSection.spec.tsx +++ b/static/app/views/detectors/components/forms/automateSection.spec.tsx @@ -17,6 +17,7 @@ import { import {selectEvent} from 'sentry-test/selectEvent'; import {Form} from 'sentry/components/forms/form'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; import {ActionGroup, ActionType} from 'sentry/types/workflowEngine/actions'; import { DataConditionHandlerGroupType, @@ -37,6 +38,7 @@ describe('AutomateSection', () => { beforeEach(() => { jest.resetAllMocks(); MockApiClient.clearMockResponses(); + ProjectsStore.loadInitialData([project]); MockApiClient.addMockResponse({ url: '/organizations/org-slug/workflows/', @@ -130,8 +132,8 @@ describe('AutomateSection', () => { it('can connect an existing automation', async () => { render( - -
+ +
@@ -165,8 +167,8 @@ describe('AutomateSection', () => { it('can disconnect an existing automation', async () => { render( - -
+ +
@@ -217,8 +219,8 @@ describe('AutomateSection', () => { }); render( - -
+ +
diff --git a/static/app/views/detectors/components/forms/automateSection.tsx b/static/app/views/detectors/components/forms/automateSection.tsx index 7e45ea3bb748..b817c2a366bd 100644 --- a/static/app/views/detectors/components/forms/automateSection.tsx +++ b/static/app/views/detectors/components/forms/automateSection.tsx @@ -15,14 +15,14 @@ import {t, tct} from 'sentry/locale'; import {AutomationBuilderDrawerForm} from 'sentry/views/automations/components/automationBuilderDrawerForm'; import {ConnectAutomationsDrawer} from 'sentry/views/detectors/components/connectAutomationsDrawer'; import {ConnectedAutomationsList} from 'sentry/views/detectors/components/connectedAutomationList'; -import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; export function AutomateSection({step}: {step?: number}) { const ref = useRef(null); const formContext = useContext(FormContext); const {openDrawer, closeDrawer, isDrawerOpen} = useDrawer(); - const {project} = useDetectorFormContext(); + const project = useDetectorFormProject(); const workflowIds = useFormField('workflowIds') as string[]; const setWorkflowIds = useCallback( (newWorkflowIds: string[]) => diff --git a/static/app/views/detectors/components/forms/common/projectField.tsx b/static/app/views/detectors/components/forms/common/projectField.tsx index 8d401a4f3bdc..23b24d7dd832 100644 --- a/static/app/views/detectors/components/forms/common/projectField.tsx +++ b/static/app/views/detectors/components/forms/common/projectField.tsx @@ -3,12 +3,14 @@ import styled from '@emotion/styled'; import {SentryProjectSelectorField} from 'sentry/components/forms/fields/sentryProjectSelectorField'; import {t} from 'sentry/locale'; import {useProjects} from 'sentry/utils/useProjects'; +import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; import {useCanEditDetector} from 'sentry/views/detectors/utils/useCanEditDetector'; export function ProjectField() { const {projects, fetching} = useProjects(); - const {project, detectorType} = useDetectorFormContext(); + const {detectorType} = useDetectorFormContext(); + const project = useDetectorFormProject(); const canEditDetector = useCanEditDetector({projectId: project.id, detectorType}); return ( diff --git a/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx b/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx new file mode 100644 index 000000000000..731f75278f66 --- /dev/null +++ b/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx @@ -0,0 +1,21 @@ +import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; +import type {Project} from 'sentry/types/project'; +import {useProjects} from 'sentry/utils/useProjects'; + +/** + * Returns the current project based on the form's `projectId` field. + * Reactively updates when the user changes the project in the form. + * + * Must be used within a Form that has a `projectId` field. + */ +export function useDetectorFormProject(): Project { + const projectId = useFormField('projectId'); + const {projects} = useProjects(); + const project = projects.find(p => p.id === projectId); + if (!project) { + throw new Error( + `useDetectorFormProject: no project found for projectId "${projectId}"` + ); + } + return project; +} diff --git a/static/app/views/detectors/components/forms/common/useSetAutomaticName.spec.tsx b/static/app/views/detectors/components/forms/common/useSetAutomaticName.spec.tsx index 4fe59334e85e..b1982f75cd6a 100644 --- a/static/app/views/detectors/components/forms/common/useSetAutomaticName.spec.tsx +++ b/static/app/views/detectors/components/forms/common/useSetAutomaticName.spec.tsx @@ -34,7 +34,7 @@ describe('useSetAutomaticName', () => { const renderDetectorForm = (detector?: Detector, initialFormData = {}) => { return render( - + data as any} diff --git a/static/app/views/detectors/components/forms/context.tsx b/static/app/views/detectors/components/forms/context.tsx index de83ccab0c5a..ae5ab92e4d23 100644 --- a/static/app/views/detectors/components/forms/context.tsx +++ b/static/app/views/detectors/components/forms/context.tsx @@ -1,6 +1,5 @@ import {createContext, useContext, useState} from 'react'; -import type {Project} from 'sentry/types/project'; import type {Detector, DetectorType} from 'sentry/types/workflowEngine/detectors'; type DetectorFormContextType = { @@ -10,7 +9,6 @@ type DetectorFormContextType = { * Used by useSetAutomaticName to disable automatic name generation. */ hasSetDetectorName: boolean; - project: Project; setHasSetDetectorName: (value: boolean) => void; detector?: Detector; }; @@ -19,20 +17,18 @@ const DetectorFormContext = createContext(null); export function DetectorFormProvider({ detectorType, - project, detector, children, }: { children: React.ReactNode; detectorType: DetectorType; - project: Project; detector?: Detector; }) { const [hasSetDetectorName, setHasSetDetectorName] = useState(false); return ( {children} diff --git a/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx b/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx index 6269b074e045..9952ebc73c56 100644 --- a/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx +++ b/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx @@ -2,7 +2,7 @@ import {t} from 'sentry/locale'; import {DetectorIssuePreview} from 'sentry/views/detectors/components/forms/common/detectorIssuePreview'; import {IssuePreviewSection} from 'sentry/views/detectors/components/forms/common/issuePreviewSection'; import {ownerToActor} from 'sentry/views/detectors/components/forms/common/ownerToActor'; -import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; import {useCronDetectorFormField} from 'sentry/views/detectors/components/forms/cron/fields'; const FALLBACK_ISSUE_TITLE = t('Cron failure: …'); @@ -22,7 +22,7 @@ export function CronIssuePreview({step}: {step?: number}) { const owner = useCronDetectorFormField('owner'); const issueTitle = useCronIssueTitle(); const assignee = ownerToActor(owner); - const {project} = useDetectorFormContext(); + const project = useDetectorFormProject(); return ( diff --git a/static/app/views/detectors/components/forms/cron/index.spec.tsx b/static/app/views/detectors/components/forms/cron/index.spec.tsx index 8ccf62848d5b..a4cba9dc59dd 100644 --- a/static/app/views/detectors/components/forms/cron/index.spec.tsx +++ b/static/app/views/detectors/components/forms/cron/index.spec.tsx @@ -43,7 +43,7 @@ describe('NewCronDetectorForm', () => { const renderForm = (routerConfig?: any) => { return render( - + , { diff --git a/static/app/views/detectors/components/forms/error/index.tsx b/static/app/views/detectors/components/forms/error/index.tsx index 4888b9082caa..720d0c0b9535 100644 --- a/static/app/views/detectors/components/forms/error/index.tsx +++ b/static/app/views/detectors/components/forms/error/index.tsx @@ -141,6 +141,7 @@ export function EditExistingErrorDetectorForm({detector}: {detector: ErrorDetect { MockApiClient.clearMockResponses(); OrganizationStore.reset(); OrganizationStore.onUpdate(organization); + ProjectsStore.loadInitialData([project]); MemberListStore.init(); MemberListStore.loadInitialData([UserFixture()]); @@ -80,7 +82,7 @@ describe('NewMetricDetectorForm', () => { it('shows default issue preview and updates subtitle when threshold changes', async () => { render( - + , {organization} @@ -143,7 +145,7 @@ describe('NewMetricDetectorForm', () => { it('removes is filters when switching away from the errors dataset', async () => { render( - + , { diff --git a/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx b/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx index f7ca68d5cbc2..d6e331a4ea8c 100644 --- a/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx +++ b/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx @@ -4,7 +4,7 @@ import {getDuration} from 'sentry/utils/duration/getDuration'; import {DetectorIssuePreview} from 'sentry/views/detectors/components/forms/common/detectorIssuePreview'; import {IssuePreviewSection} from 'sentry/views/detectors/components/forms/common/issuePreviewSection'; import {ownerToActor} from 'sentry/views/detectors/components/forms/common/ownerToActor'; -import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; import { METRIC_DETECTOR_FORM_FIELDS, useMetricDetectorFormField, @@ -81,7 +81,7 @@ export function MetricIssuePreview({step}: {step?: number}) { const owner = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.owner); const subtitle = useMetricIssuePreviewSubtitle(); const assignee = ownerToActor(owner); - const {project} = useDetectorFormContext(); + const project = useDetectorFormProject(); return ( diff --git a/static/app/views/detectors/components/forms/newDetectorLayout.tsx b/static/app/views/detectors/components/forms/newDetectorLayout.tsx index d3d685acd795..cb9c115cbfb1 100644 --- a/static/app/views/detectors/components/forms/newDetectorLayout.tsx +++ b/static/app/views/detectors/components/forms/newDetectorLayout.tsx @@ -1,5 +1,6 @@ import {useMemo, useState} from 'react'; import {useTheme} from '@emotion/react'; +import orderBy from 'lodash/orderBy'; import type {FormProps} from 'sentry/components/forms/form'; import {FormModel} from 'sentry/components/forms/model'; @@ -11,10 +12,10 @@ import type { DetectorType, } from 'sentry/types/workflowEngine/detectors'; import {useLocation} from 'sentry/utils/useLocation'; +import {useProjects} from 'sentry/utils/useProjects'; import {NewDetectorBreadcrumbs} from 'sentry/views/detectors/components/forms/common/breadcrumbs'; import {DetectorNameField} from 'sentry/views/detectors/components/forms/common/detectorNameField'; import {NewDetectorFooter} from 'sentry/views/detectors/components/forms/common/footer'; -import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; import {MonitorFeedbackButton} from 'sentry/views/detectors/components/monitorFeedbackButton'; import {useCreateDetectorFormSubmit} from 'sentry/views/detectors/hooks/useCreateDetectorFormSubmit'; @@ -45,7 +46,19 @@ export function NewDetectorLayout< const location = useLocation(); const theme = useTheme(); const maxWidth = theme.breakpoints.xl; - const formContext = useDetectorFormContext(); + const {projects} = useProjects(); + + const initialProjectId = useMemo(() => { + const queryProjectId = location.query.project as string | undefined; + if (queryProjectId) { + const match = projects.find(p => p.id === queryProjectId); + if (match) { + return match.id; + } + } + const sorted = orderBy(projects, ['isMember', 'isBookmarked'], ['desc', 'desc']); + return sorted[0]?.id ?? ''; + }, [location.query.project, projects]); const formSubmitHandler = useCreateDetectorFormSubmit({ detectorType, @@ -57,7 +70,7 @@ export function NewDetectorLayout< const initialData = useMemo(() => { return { - projectId: formContext.project.id, + projectId: initialProjectId, environment: (location.query.environment as string | undefined) || '', name: (location.query.name as string | undefined) || '', owner: (location.query.owner as string | undefined) || '', @@ -65,7 +78,7 @@ export function NewDetectorLayout< ...initialFormData, }; }, [ - formContext.project.id, + initialProjectId, initialFormData, location.query.environment, location.query.name, diff --git a/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx b/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx index 414aaabeccee..a387b1e07bfa 100644 --- a/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx +++ b/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx @@ -2,7 +2,7 @@ import {t} from 'sentry/locale'; import {DetectorIssuePreview} from 'sentry/views/detectors/components/forms/common/detectorIssuePreview'; import {IssuePreviewSection} from 'sentry/views/detectors/components/forms/common/issuePreviewSection'; import {ownerToActor} from 'sentry/views/detectors/components/forms/common/ownerToActor'; -import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; import {useUptimeDetectorFormField} from 'sentry/views/detectors/components/forms/uptime/fields'; import {formatUptimeUrl} from 'sentry/views/detectors/components/forms/uptime/formatUptimeUrl'; @@ -28,7 +28,7 @@ export function UptimeIssuePreview({step}: {step?: number}) { const owner = useUptimeDetectorFormField('owner'); const issueTitle = useUptimeIssueTitle(); const assignee = ownerToActor(owner); - const {project} = useDetectorFormContext(); + const project = useDetectorFormProject(); return ( diff --git a/static/app/views/detectors/edit.tsx b/static/app/views/detectors/edit.tsx index e3b470629360..663698fadbb2 100644 --- a/static/app/views/detectors/edit.tsx +++ b/static/app/views/detectors/edit.tsx @@ -10,6 +10,7 @@ import {useDetectorQuery} from 'sentry/views/detectors/hooks'; export default function DetectorEdit() { const params = useParams<{detectorId: string}>(); + const {fetching: isFetchingProjects} = useProjects(); useWorkflowEngineFeatureGate({redirect: true}); const { @@ -20,9 +21,6 @@ export default function DetectorEdit() { refetch, } = useDetectorQuery(params.detectorId); - const {projects, fetching: isFetchingProjects} = useProjects(); - const project = projects.find(p => p.id === detector?.projectId); - if (isPending || isFetchingProjects) { return ; } @@ -36,16 +34,8 @@ export default function DetectorEdit() { ); } - if (!project) { - return ; - } - return ( - + ); diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index e05ad2dc1df0..9f9356c6fe74 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -1,9 +1,3 @@ -import orderBy from 'lodash/orderBy'; -import {parseAsString, useQueryState} from 'nuqs'; - -import {Stack} from '@sentry/scraps/layout'; - -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; @@ -19,41 +13,20 @@ import { } from 'sentry/views/detectors/utils/detectorTypeConfig'; export default function DetectorNewSettings() { - const {projects, fetching: isFetchingProjects} = useProjects(); + const {fetching: isFetchingProjects} = useProjects(); const [detectorType] = useDetectorTypeQueryState(); - const [projectId] = useQueryState('project', parseAsString); useWorkflowEngineFeatureGate({redirect: true}); - if (!detectorType || !isValidDetectorType(detectorType)) { - return ; - } - if (isFetchingProjects) { - return ( - - - - - - - - ); + return ; } - const sortedProjects = orderBy( - projects, - ['isMember', 'isBookmarked'], - ['desc', 'desc'] - ); - const project = - (projectId ? projects.find(p => p.id === projectId) : null) ?? sortedProjects[0]; - - if (!project) { - return ; + if (!detectorType || !isValidDetectorType(detectorType)) { + return ; } return ( - + From 04c3e004b0984ea4087105acc5d90d38d0adda22 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 8 Apr 2026 09:40:17 -0700 Subject: [PATCH 2/2] Add error boundary and explanatory comment --- .../workflowEngine/ui/formSection.tsx | 52 ++++++++++--------- .../forms/common/useDetectorFormProject.tsx | 15 +++--- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/static/app/components/workflowEngine/ui/formSection.tsx b/static/app/components/workflowEngine/ui/formSection.tsx index 339a386dce6d..15acd7e03949 100644 --- a/static/app/components/workflowEngine/ui/formSection.tsx +++ b/static/app/components/workflowEngine/ui/formSection.tsx @@ -4,6 +4,8 @@ import {Disclosure} from '@sentry/scraps/disclosure'; import {Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; +import {ErrorBoundary} from 'sentry/components/errorBoundary'; + type FormSectionProps = { title: React.ReactNode; children?: React.ReactNode; @@ -24,30 +26,32 @@ export function FormSection({ defaultExpanded = true, }: FormSectionProps) { return ( - - - - {step ? `${step}. ` : ''} - {title} - - - - - {description && ( - - {description} - - )} - {children} - - - + + + + + {step ? `${step}. ` : ''} + {title} + + + + + {description && ( + + {description} + + )} + {children} + + + + ); } diff --git a/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx b/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx index 731f75278f66..b38d576277ab 100644 --- a/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx +++ b/static/app/views/detectors/components/forms/common/useDetectorFormProject.tsx @@ -1,17 +1,18 @@ +import {useMemo} from 'react'; + import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; import type {Project} from 'sentry/types/project'; import {useProjects} from 'sentry/utils/useProjects'; -/** - * Returns the current project based on the form's `projectId` field. - * Reactively updates when the user changes the project in the form. - * - * Must be used within a Form that has a `projectId` field. - */ export function useDetectorFormProject(): Project { const projectId = useFormField('projectId'); const {projects} = useProjects(); - const project = projects.find(p => p.id === projectId); + const project = useMemo( + () => projects.find(p => p.id === projectId), + [projects, projectId] + ); + // There's a top-level spinner when projects are loading, and you can't select a + // project that's not in the ProjectStore, so this should never happen. if (!project) { throw new Error( `useDetectorFormProject: no project found for projectId "${projectId}"`