diff --git a/static/app/components/workflowEngine/form/control/index.stories.tsx b/static/app/components/workflowEngine/form/control/index.stories.tsx index 2c4febfcb1c9ec..f4f91b501f610a 100644 --- a/static/app/components/workflowEngine/form/control/index.stories.tsx +++ b/static/app/components/workflowEngine/form/control/index.stories.tsx @@ -5,6 +5,7 @@ import Form from 'sentry/components/forms/form'; import PriorityControl from 'sentry/components/workflowEngine/form/control/priorityControl'; import * as Storybook from 'sentry/stories'; import {space} from 'sentry/styles/space'; +import {DetectorPriorityLevel} from 'sentry/types/workflowEngine/dataConditions'; export default Storybook.story('Form Controls', story => { story('PriorityControl', () => ( @@ -16,7 +17,7 @@ export default Storybook.story('Form Controls', story => {
- +
diff --git a/static/app/components/workflowEngine/form/control/priorityControl.spec.tsx b/static/app/components/workflowEngine/form/control/priorityControl.spec.tsx index 54da50adea6af6..603e5cb03e4907 100644 --- a/static/app/components/workflowEngine/form/control/priorityControl.spec.tsx +++ b/static/app/components/workflowEngine/form/control/priorityControl.spec.tsx @@ -19,7 +19,7 @@ describe('PriorityControl', function () { }); render(
- + ); @@ -37,7 +37,7 @@ describe('PriorityControl', function () { }); render(
- + ); expect(await screen.findByRole('button', {name: 'Low'})).toBeInTheDocument(); @@ -62,7 +62,7 @@ describe('PriorityControl', function () { }); render(
- + ); const medium = await screen.findByTestId('priority-control-medium'); diff --git a/static/app/components/workflowEngine/form/control/priorityControl.tsx b/static/app/components/workflowEngine/form/control/priorityControl.tsx index 17ebffabf0ae76..0a004feaa37439 100644 --- a/static/app/components/workflowEngine/form/control/priorityControl.tsx +++ b/static/app/components/workflowEngine/form/control/priorityControl.tsx @@ -4,7 +4,6 @@ import styled from '@emotion/styled'; import {GroupPriorityBadge} from 'sentry/components/badge/groupPriority'; import {Flex} from 'sentry/components/container/flex'; import {CompactSelect} from 'sentry/components/core/compactSelect'; -import {FieldWrapper} from 'sentry/components/forms/fieldGroup/fieldWrapper'; import NumberField from 'sentry/components/forms/fields/numberField'; import FormContext from 'sentry/components/forms/formContext'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; @@ -67,12 +66,10 @@ function ChangePriority() { } interface PriorityControlProps { - minimumPriority?: DetectorPriorityLevel; + minimumPriority: DetectorPriorityLevel; } -export default function PriorityControl({ - minimumPriority = DetectorPriorityLevel.LOW, -}: PriorityControlProps) { +export default function PriorityControl({minimumPriority}: PriorityControlProps) { // TODO: kind type not yet available from detector types const detectorKind = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.kind); const initialPriorityLevel = useMetricDetectorFormField( @@ -84,7 +81,7 @@ export default function PriorityControl({ - {!detectorKind || detectorKind === 'threshold' ? ( + {!detectorKind || detectorKind === 'static' ? ( ) : ( @@ -97,7 +94,7 @@ export default function PriorityControl({ {priorityIsConfigurable(initialPriorityLevel, DetectorPriorityLevel.MEDIUM) && ( - - {left} - + {left} - - {right} - + {right} ); } @@ -227,13 +220,16 @@ const Row = styled('div')` display: contents; `; -const Cell = styled(Flex)` +const Cell = styled('div')` + display: flex; + align-items: center; + justify-content: center; padding: ${space(1)}; +`; - ${FieldWrapper} { - padding: 0; - width: 5rem; - } +const SmallNumberField = styled(NumberField)` + width: 5rem; + padding: 0; `; const SecondaryLabel = styled('div')` diff --git a/static/app/types/workflowEngine/detectors.tsx b/static/app/types/workflowEngine/detectors.tsx index 1165a2006e02db..b02cc02727d748 100644 --- a/static/app/types/workflowEngine/detectors.tsx +++ b/static/app/types/workflowEngine/detectors.tsx @@ -1,4 +1,5 @@ import type {DataConditionGroup} from 'sentry/types/workflowEngine/dataConditions'; +import type {AlertRuleSensitivity} from 'sentry/views/alerts/rules/metric/types'; /** * See SnubaQuerySerializer @@ -60,9 +61,42 @@ type DataSource = SnubaQueryDataSource | UptimeSubscriptionDataSource; export type DetectorType = 'error' | 'metric_issue' | 'uptime_domain_failure'; +interface BaseDetectorConfig { + threshold_period: number; +} + +/** + * Configuration for static/threshold-based detection + */ +interface StaticDetectorConfig extends BaseDetectorConfig { + detection_type: 'static'; +} + +/** + * Configuration for percentage-based change detection + */ +interface PercentDetectorConfig extends BaseDetectorConfig { + comparison_delta: number; + detection_type: 'percent'; +} + +/** + * Configuration for dynamic/anomaly detection + */ +interface DynamicDetectorConfig extends BaseDetectorConfig { + detection_type: 'dynamic'; + seasonality?: 'auto' | 'daily' | 'weekly' | 'monthly'; + sensitivity?: AlertRuleSensitivity; +} + +export type DetectorConfig = + | StaticDetectorConfig + | PercentDetectorConfig + | DynamicDetectorConfig; + interface NewDetector { conditionGroup: DataConditionGroup | null; - config: Record; + config: DetectorConfig; dataSources: DataSource[] | null; disabled: boolean; name: string; diff --git a/static/app/views/detectors/components/detectorSubtitle.tsx b/static/app/views/detectors/components/detectorSubtitle.tsx new file mode 100644 index 00000000000000..0db7779b3e3a58 --- /dev/null +++ b/static/app/views/detectors/components/detectorSubtitle.tsx @@ -0,0 +1,27 @@ +import {Flex} from 'sentry/components/container/flex'; +import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import useProjects from 'sentry/utils/useProjects'; + +interface DetectorSubtitleProps { + environment: string; + projectId: string; +} + +export function DetectorSubtitle({projectId, environment}: DetectorSubtitleProps) { + const {projects} = useProjects(); + const project = projects.find(p => p.id === projectId); + return ( + + {project && ( + + + {project.slug} + + )} +
|
+
{environment || t('All Environments')}
+
+ ); +} diff --git a/static/app/views/detectors/components/forms/fullHeightForm.tsx b/static/app/views/detectors/components/forms/fullHeightForm.tsx new file mode 100644 index 00000000000000..a214a1e5d9b794 --- /dev/null +++ b/static/app/views/detectors/components/forms/fullHeightForm.tsx @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; + +import Form from 'sentry/components/forms/form'; + +/** + * Extends the Form component to be full height and have a sticky footer. + */ +export const FullHeightForm = styled(Form)` + display: flex; + flex-direction: column; + flex: 1 1 0%; + + & > div:first-child { + display: flex; + flex-direction: column; + flex: 1; + } +`; diff --git a/static/app/views/detectors/components/forms/metric.tsx b/static/app/views/detectors/components/forms/metric.tsx index 65ebb5bc74da22..6f89c9e9cc1d2d 100644 --- a/static/app/views/detectors/components/forms/metric.tsx +++ b/static/app/views/detectors/components/forms/metric.tsx @@ -16,7 +16,10 @@ import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {TagCollection} from 'sentry/types/group'; -import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions'; +import { + DataConditionType, + DetectorPriorityLevel, +} from 'sentry/types/workflowEngine/dataConditions'; import { ALLOWED_EXPLORE_VISUALIZE_AGGREGATES, FieldKey, @@ -48,12 +51,8 @@ export function MetricDetectorForm() { function MonitorKind() { const options: Array<[MetricDetectorFormData['kind'], string, string]> = [ - [ - 'threshold', - t('Threshold'), - t('Absolute-valued thresholds, for non-seasonal data.'), - ], - ['change', t('Change'), t('Percentage changes over defined time windows.')], + ['static', t('Threshold'), t('Absolute-valued thresholds, for non-seasonal data.')], + ['percent', t('Change'), t('Percentage changes over defined time windows.')], [ 'dynamic', t('Dynamic'), @@ -63,7 +62,7 @@ function MonitorKind() { return ( - {kind !== 'dynamic' && } + {kind !== 'dynamic' && ( + + )} ); @@ -193,7 +194,7 @@ function DetectSection() { - {(!kind || kind === 'threshold') && ( + {(!kind || kind === 'static') && ( {t('An issue will be created when query value is:')} @@ -224,7 +225,7 @@ function DetectSection() { )} - {kind === 'change' && ( + {kind === 'percent' && ( {t('An issue will be created when query value is:')} diff --git a/static/app/views/detectors/components/forms/metricFormData.tsx b/static/app/views/detectors/components/forms/metricFormData.tsx index f508dd7a30ae73..142fd031341e55 100644 --- a/static/app/views/detectors/components/forms/metricFormData.tsx +++ b/static/app/views/detectors/components/forms/metricFormData.tsx @@ -8,8 +8,9 @@ import { DataConditionType, DetectorPriorityLevel, } from 'sentry/types/workflowEngine/dataConditions'; -import type {Detector} from 'sentry/types/workflowEngine/detectors'; +import type {Detector, DetectorConfig} from 'sentry/types/workflowEngine/detectors'; import {defined} from 'sentry/utils'; +import {parseFunction} from 'sentry/utils/discover/fields'; import { AlertRuleSensitivity, AlertRuleThresholdType, @@ -61,7 +62,7 @@ export interface MetricDetectorFormData MetricDetectorDynamicFormData { aggregate: string; environment: string; - kind: 'threshold' | 'change' | 'dynamic'; + kind: 'static' | 'percent' | 'dynamic'; name: string; projectId: string; query: string; @@ -101,7 +102,7 @@ export const METRIC_DETECTOR_FORM_FIELDS = { } satisfies Record; export const DEFAULT_THRESHOLD_METRIC_FORM_DATA = { - kind: 'threshold', + kind: 'static', // Priority level fields // Metric detectors only support MEDIUM and HIGH priority levels @@ -153,16 +154,11 @@ interface NewDataSource { export interface NewMetricDetector { conditionGroup: NewConditionGroup; - // TODO: config types don't exist yet - config: { - // TODO: what is the shape of config? - detection_type: any; - threshold_period: number; - }; + config: DetectorConfig; dataSource: NewDataSource; // Single data source object (not array) - detectorType: Detector['type']; name: string; projectId: Detector['projectId']; + type: Detector['type']; } /** @@ -195,7 +191,7 @@ function createConditions(data: MetricDetectorFormData): NewConditionGroup['cond } // Create resolution condition if provided - if (defined(data.resolveThreshold)) { + if (defined(data.resolveThreshold) && data.resolveThreshold !== '') { // Resolution condition uses opposite comparison type const resolveConditionType = data.conditionType === DataConditionType.GREATER @@ -204,7 +200,7 @@ function createConditions(data: MetricDetectorFormData): NewConditionGroup['cond conditions.push({ type: resolveConditionType, - comparison: data.resolveThreshold, + comparison: parseFloat(data.resolveThreshold) || 0, conditionResult: DetectorPriorityLevel.OK, }); } @@ -226,7 +222,8 @@ function createDataSource(data: MetricDetectorFormData): NewDataSource { query: data.query, // TODO: aggregate doesn't always contain the selected "visualize" value. aggregate: `${data.aggregate}(${data.visualize})`, - timeWindow: data.conditionComparisonAgo ? data.conditionComparisonAgo / 60 : 60, + // TODO: Add interval to the form + timeWindow: 60 * 60, environment: data.environment ? data.environment : null, eventTypes, }; @@ -238,19 +235,154 @@ export function getNewMetricDetectorData( const conditions = createConditions(data); const dataSource = createDataSource(data); + // Create config based on detection type + let config: DetectorConfig; + switch (data.kind) { + case 'percent': + config = { + threshold_period: 1, + detection_type: 'percent', + comparison_delta: data.conditionComparisonAgo || 3600, + }; + break; + case 'dynamic': + config = { + threshold_period: 1, + detection_type: 'dynamic', + sensitivity: data.sensitivity, + }; + break; + case 'static': + default: + config = { + threshold_period: 1, + detection_type: 'static', + }; + break; + } + return { name: data.name || 'New Monitor', - detectorType: 'metric_issue', + type: 'metric_issue', projectId: data.projectId, conditionGroup: { // TODO: Can this be different values? logicType: DataConditionGroupLogicType.ANY, conditions, }, - config: { - threshold_period: 1, - detection_type: 'static', - }, + config, dataSource, }; } + +/** + * Convert the detector conditions array to the flattened form data + */ +function processDetectorConditions( + detector: Detector +): PrioritizeLevelFormData & + Pick { + // Get conditions from the condition group + const conditions = detector.conditionGroup?.conditions || []; + // Sort by priority level, lowest first + const sortedConditions = conditions.toSorted((a, b) => { + return (a.conditionResult || 0) - (b.conditionResult || 0); + }); + + // Find the condition with the lowest non-zero priority level + const mainCondition = sortedConditions.find( + condition => condition.conditionResult !== DetectorPriorityLevel.OK + ); + + // Find high priority escalation condition + const highCondition = conditions.find( + condition => condition.conditionResult === DetectorPriorityLevel.HIGH + ); + + // Find resolution condition + const resolveCondition = conditions.find( + condition => condition.conditionResult === DetectorPriorityLevel.OK + ); + + // Determine initial priority level, ensuring it's valid for the form + let initialPriorityLevel: DetectorPriorityLevel.MEDIUM | DetectorPriorityLevel.HIGH = + DetectorPriorityLevel.MEDIUM; + + if (mainCondition?.conditionResult === DetectorPriorityLevel.HIGH) { + initialPriorityLevel = DetectorPriorityLevel.HIGH; + } else if (mainCondition?.conditionResult === DetectorPriorityLevel.MEDIUM) { + initialPriorityLevel = DetectorPriorityLevel.MEDIUM; + } + + // Ensure condition type is valid for the form + let conditionType: DataConditionType.GREATER | DataConditionType.LESS = + DataConditionType.GREATER; + if ( + mainCondition?.type === DataConditionType.LESS || + mainCondition?.type === DataConditionType.GREATER + ) { + conditionType = mainCondition.type; + } + + return { + initialPriorityLevel, + conditionValue: mainCondition?.comparison.toString() || '', + conditionType, + highThreshold: highCondition?.comparison.toString() || '', + resolveThreshold: resolveCondition?.comparison.toString() || '', + }; +} + +/** + * Converts a Detector to MetricDetectorFormData for editing + */ +export function getMetricDetectorFormData(detector: Detector): MetricDetectorFormData { + // Get the first data source (assuming metric detectors have one) + const dataSource = detector.dataSources?.[0]; + + // Check if this is a snuba query data source + const snubaQuery = + dataSource?.type === 'snuba_query_subscription' + ? dataSource.queryObj?.snubaQuery + : undefined; + + // Extract aggregate and visualize from the aggregate string + const parsedFunction = snubaQuery?.aggregate + ? parseFunction(snubaQuery.aggregate) + : null; + const aggregate = parsedFunction?.name || 'count'; + const visualize = parsedFunction?.arguments[0] || 'transaction.duration'; + + // Process conditions using the extracted function + const conditionData = processDetectorConditions(detector); + + return { + // Core detector fields + name: detector.name, + projectId: detector.projectId, + environment: snubaQuery?.environment || '', + query: snubaQuery?.query || '', + aggregate, + visualize, + + // Priority level and condition fields from processed conditions + ...conditionData, + kind: detector.config.detection_type || 'static', + + // Condition fields - get comparison delta from detector config (already in seconds) + conditionComparisonAgo: + (detector.config?.detection_type === 'percent' + ? detector.config.comparison_delta + : null) || 3600, + + // Dynamic fields - extract from config for dynamic detectors + sensitivity: + detector.config?.detection_type === 'dynamic' + ? detector.config.sensitivity || AlertRuleSensitivity.LOW + : AlertRuleSensitivity.LOW, + thresholdType: + detector.config?.detection_type === 'dynamic' + ? (detector.config as any).threshold_type || AlertRuleThresholdType.ABOVE + : AlertRuleThresholdType.ABOVE, + }; +} diff --git a/static/app/views/detectors/edit.tsx b/static/app/views/detectors/edit.tsx index 92e94e20ead0e4..6c667aee37133e 100644 --- a/static/app/views/detectors/edit.tsx +++ b/static/app/views/detectors/edit.tsx @@ -1,56 +1,152 @@ -/* eslint-disable no-alert */ -import {Fragment, useState} from 'react'; +import {useCallback, useMemo} from 'react'; +import styled from '@emotion/styled'; +import Breadcrumbs from 'sentry/components/breadcrumbs'; +import {Flex} from 'sentry/components/container/flex'; import {Button} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; +import type {OnSubmitCallback} from 'sentry/components/forms/types'; +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'; -import {ActionsProvider} from 'sentry/components/workflowEngine/layout/actions'; -import {BreadcrumbsProvider} from 'sentry/components/workflowEngine/layout/breadcrumbs'; -import EditLayout from 'sentry/components/workflowEngine/layout/edit'; +import {StickyFooter} from 'sentry/components/workflowEngine/ui/footer'; import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate'; import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; +import {useParams} from 'sentry/utils/useParams'; +import {DetectorSubtitle} from 'sentry/views/detectors/components/detectorSubtitle'; +import {EditableDetectorName} from 'sentry/views/detectors/components/forms/editableDetectorName'; +import {FullHeightForm} from 'sentry/views/detectors/components/forms/fullHeightForm'; import {MetricDetectorForm} from 'sentry/views/detectors/components/forms/metric'; -import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; +import type {MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metricFormData'; +import { + getMetricDetectorFormData, + getNewMetricDetectorData, + useMetricDetectorFormField, +} from 'sentry/views/detectors/components/forms/metricFormData'; +import {useDetectorQuery, useUpdateDetector} from 'sentry/views/detectors/hooks'; +import { + makeMonitorBasePathname, + makeMonitorDetailsPathname, +} from 'sentry/views/detectors/pathnames'; + +function DetectorBreadcrumbs({detectorId}: {detectorId: string}) { + const title = useMetricDetectorFormField('name'); + const organization = useOrganization(); + return ( + + ); +} + +function DetectorDocumentTitle() { + const title = useMetricDetectorFormField('name'); + return ; +} export default function DetectorEdit() { const organization = useOrganization(); + const navigate = useNavigate(); + const params = useParams<{detectorId: string}>(); + useWorkflowEngineFeatureGate({redirect: true}); - const [title, setTitle] = useState(t('Edit Monitor')); - return ( - - - }> - - - - - - + const { + data: detector, + isPending, + isError, + refetch, + } = useDetectorQuery(params.detectorId); + + const {mutateAsync: updateDetector} = useUpdateDetector(); + + const handleSubmit = useCallback( + async (data, _, __, ___, formModel) => { + if (!detector) { + return; + } + + const isValid = formModel.validateForm(); + if (!isValid) { + return; + } + + const updatedData = { + detectorId: detector.id, + ...getNewMetricDetectorData(data as MetricDetectorFormData), + }; + + const updatedDetector = await updateDetector(updatedData); + navigate(makeMonitorDetailsPathname(organization.slug, updatedDetector.id)); + }, + [updateDetector, navigate, organization.slug, detector] ); -} -function Actions() { - const disable = () => { - window.alert('disable'); - }; - const del = () => { - window.alert('delete'); - }; - const save = () => { - window.alert('save'); - }; + const initialData = useMemo((): MetricDetectorFormData | null => { + if (!detector) { + return null; + } + + return getMetricDetectorFormData(detector); + }, [detector]); + + if (isPending && !initialData) { + return ; + } + + if (isError || !detector || !initialData) { + return ; + } + return ( - - - - - + + + + + + + + + + + + + + + + + + + + + + + + {t('Cancel')} + + + + + ); } + +const StyledLayoutHeader = styled(Layout.Header)` + background-color: ${p => p.theme.background}; +`; diff --git a/static/app/views/detectors/hooks/index.ts b/static/app/views/detectors/hooks/index.ts index cd11ab756f9822..38686fd405da9b 100644 --- a/static/app/views/detectors/hooks/index.ts +++ b/static/app/views/detectors/hooks/index.ts @@ -92,6 +92,31 @@ export function useCreateDetector() { }); } +export function useUpdateDetector() { + const org = useOrganization(); + const api = useApi({persistInFlight: true}); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: data => + api.requestPromise(`/organizations/${org.slug}/detectors/${data.detectorId}/`, { + method: 'PUT', + data, + }), + onSuccess: (_, data) => { + queryClient.invalidateQueries({ + queryKey: [`/organizations/${org.slug}/detectors/`], + }); + queryClient.invalidateQueries({ + queryKey: [`/organizations/${org.slug}/detectors/${data.detectorId}/`], + }); + }, + onError: _ => { + AlertStore.addAlert({type: 'error', message: t('Unable to update monitor')}); + }, + }); +} + const makeDetectorDetailsQueryKey = ({ orgSlug, detectorId, diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index 09df5e7ec91ef4..92e3a150a75e7d 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -1,15 +1,13 @@ -import {useCallback} from 'react'; +import {useCallback, useLayoutEffect, useMemo, useRef} from 'react'; import styled from '@emotion/styled'; -import {Breadcrumbs} from 'sentry/components/breadcrumbs'; +import Breadcrumbs from 'sentry/components/breadcrumbs'; import {Flex} from 'sentry/components/container/flex'; import {Button} from 'sentry/components/core/button'; import {LinkButton} from 'sentry/components/core/button/linkButton'; -import Form from 'sentry/components/forms/form'; import type {OnSubmitCallback} from 'sentry/components/forms/types'; import * as Layout from 'sentry/components/layouts/thirds'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; -import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; import { StickyFooter, StickyFooterLabel, @@ -20,12 +18,15 @@ import {space} from 'sentry/styles/space'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; +import {DetectorSubtitle} from 'sentry/views/detectors/components/detectorSubtitle'; import {EditableDetectorName} from 'sentry/views/detectors/components/forms/editableDetectorName'; +import {FullHeightForm} from 'sentry/views/detectors/components/forms/fullHeightForm'; import {MetricDetectorForm} from 'sentry/views/detectors/components/forms/metric'; import type {MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metricFormData'; import { DEFAULT_THRESHOLD_METRIC_FORM_DATA, getNewMetricDetectorData, + useMetricDetectorFormField, } from 'sentry/views/detectors/components/forms/metricFormData'; import {useCreateDetector} from 'sentry/views/detectors/hooks'; import { @@ -33,8 +34,17 @@ import { makeMonitorDetailsPathname, } from 'sentry/views/detectors/pathnames'; -function NewDetectorBreadcrumbs() { - const title = useFormField('title'); +function DetectorDocumentTitle() { + const title = useMetricDetectorFormField('name'); + return ( + + ); +} + +function DetectorBreadcrumbs() { + const title = useMetricDetectorFormField('name'); const organization = useOrganization(); return ( ('title'); - return ; -} - export default function DetectorNewSettings() { - const location = useLocation(); const organization = useOrganization(); - useWorkflowEngineFeatureGate({redirect: true}); const navigate = useNavigate(); + const location = useLocation(); + // We'll likely use more query params on this page to open drawers, validate once + const validatedRequiredQueryParams = useRef(false); + + useWorkflowEngineFeatureGate({redirect: true}); + + // Kick user back to the previous step if they don't have a project or detectorType + useLayoutEffect(() => { + const {project, detectorType} = location.query; + if (validatedRequiredQueryParams.current) { + return; + } + + if (!project || !detectorType) { + navigate(`${makeMonitorBasePathname(organization.slug)}new/`); + } + validatedRequiredQueryParams.current = true; + }, [location.query, navigate, organization.slug]); const {mutateAsync: createDetector} = useCreateDetector(); @@ -74,26 +95,33 @@ export default function DetectorNewSettings() { [createDetector, navigate, organization.slug] ); + // Defaults and data from the previous step passed in as query params + const initialData = useMemo( + (): MetricDetectorFormData => ({ + ...DEFAULT_THRESHOLD_METRIC_FORM_DATA, + projectId: (location.query.project as string) ?? '', + environment: (location.query.environment as string | undefined) || '', + name: (location.query.name as string | undefined) || '', + }), + [location.query] + ); + return ( - - + + - - - - + + + + + + + @@ -123,15 +151,3 @@ export default function DetectorNewSettings() { const StyledLayoutHeader = styled(Layout.Header)` background-color: ${p => p.theme.background}; `; - -const FullHeightForm = styled(Form)` - display: flex; - flex-direction: column; - flex: 1 1 0%; - - & > div:first-child { - display: flex; - flex-direction: column; - flex: 1; - } -`; diff --git a/tests/js/fixtures/detectors.ts b/tests/js/fixtures/detectors.ts index a254fdd7ba04e5..d4ee1b71fd82c0 100644 --- a/tests/js/fixtures/detectors.ts +++ b/tests/js/fixtures/detectors.ts @@ -13,7 +13,10 @@ export function DetectorFixture(params: Partial = {}): Detector { dateUpdated: '2025-01-01T00:00:00.000Z', lastTriggered: '2025-01-01T00:00:00.000Z', workflowIds: [], - config: {}, + config: { + detection_type: 'static', + threshold_period: 1, + }, type: 'metric_issue', disabled: false, conditionGroup: params.conditionGroup ?? DataConditionGroupFixture(),