diff --git a/static/app/views/detectors/components/forms/uptime/detect.tsx b/static/app/views/detectors/components/forms/uptime/detect.tsx deleted file mode 100644 index b5497281429bb2..00000000000000 --- a/static/app/views/detectors/components/forms/uptime/detect.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {Container} from 'sentry/components/workflowEngine/ui/container'; -import Section from 'sentry/components/workflowEngine/ui/section'; -import {t} from 'sentry/locale'; -import {SectionLabel} from 'sentry/views/detectors/components/forms/sectionLabel'; - -export function UptimeDetectorFormDetectSection() { - return ( - -
- {t('Interval')} -
-
- ); -} diff --git a/static/app/views/detectors/components/forms/uptime/detect/index.tsx b/static/app/views/detectors/components/forms/uptime/detect/index.tsx new file mode 100644 index 00000000000000..0578d8a61ac3ab --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/detect/index.tsx @@ -0,0 +1,153 @@ +import styled from '@emotion/styled'; + +import {Alert} from 'sentry/components/core/alert'; +import {FieldWrapper} from 'sentry/components/forms/fieldGroup/fieldWrapper'; +import BooleanField from 'sentry/components/forms/fields/booleanField'; +import RangeField from 'sentry/components/forms/fields/rangeField'; +import SelectField from 'sentry/components/forms/fields/selectField'; +import TextareaField from 'sentry/components/forms/fields/textareaField'; +import TextField from 'sentry/components/forms/fields/textField'; +import type FormModel from 'sentry/components/forms/model'; +import ExternalLink from 'sentry/components/links/externalLink'; +import {Container} from 'sentry/components/workflowEngine/ui/container'; +import Section from 'sentry/components/workflowEngine/ui/section'; +import {t, tct} from 'sentry/locale'; +import getDuration from 'sentry/utils/duration/getDuration'; +import {UptimeHeadersField} from 'sentry/views/detectors/components/forms/uptime/detect/uptimeHeadersField'; + +const HTTP_METHOD_OPTIONS = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']; +const HTTP_METHODS_NO_BODY = ['GET', 'HEAD', 'OPTIONS']; +const MINUTE = 60; +const VALID_INTERVALS_SEC = [ + MINUTE * 1, + MINUTE * 5, + MINUTE * 10, + MINUTE * 20, + MINUTE * 30, + MINUTE * 60, +]; + +function methodHasBody(model: FormModel) { + return !HTTP_METHODS_NO_BODY.includes(model.getValue('method')); +} + +export function UptimeDetectorFormDetectSection() { + return ( + +
+ +
+ ({ + value, + label: t('Every %s', getDuration(value)), + }))} + name="intervalSeconds" + label={t('Interval')} + defaultValue={60} + flexibleControlStateSize + showHelpInTooltip={{isHoverable: true}} + help={({model}) => + tct( + 'The amount of time between each uptime check request. Selecting a period of [interval] means it will take at least [expectedFailureInterval] until you are notified of a failure. [link:Learn more].', + { + link: ( + + ), + interval: ( + {getDuration(model.getValue('intervalSeconds'))} + ), + expectedFailureInterval: ( + + {getDuration(Number(model.getValue('intervalSeconds')) * 3)} + + ), + } + ) + } + required + /> + getDuration((value || 0) / 1000, 2, true)} + flexibleControlStateSize + required + /> + + ({ + value: option, + label: option, + }))} + flexibleControlStateSize + required + /> + + methodHasBody(model)} + rows={4} + maxRows={15} + autosize + monospace + placeholder='{"key": "value"}' + flexibleControlStateSize + /> + + ), + } + )} + flexibleControlStateSize + /> +
+ + {tct( + 'By enabling uptime monitoring, you acknowledge that uptime check data may be stored outside your selected data region. [link:Learn more].', + { + link: ( + + ), + } + )} + +
+
+
+ ); +} + +const DetectFieldsContainer = styled('div')` + ${FieldWrapper} { + padding-left: 0; + } +`; diff --git a/static/app/views/detectors/components/forms/uptime/detect/uptimeHeadersField.tsx b/static/app/views/detectors/components/forms/uptime/detect/uptimeHeadersField.tsx new file mode 100644 index 00000000000000..e77468d0192b8d --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/detect/uptimeHeadersField.tsx @@ -0,0 +1,171 @@ +import {useEffect, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Button} from 'sentry/components/core/button'; +import {Input} from 'sentry/components/core/input'; +import type {FormFieldProps} from 'sentry/components/forms/formField'; +import FormField from 'sentry/components/forms/formField'; +import FormFieldControlState from 'sentry/components/forms/formField/controlState'; +import {IconAdd, IconDelete} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {uniqueId} from 'sentry/utils/guid'; + +/** + * Matches characters that are not valid in a header name. + */ +const INVALID_NAME_HEADER_REGEX = new RegExp(/[^a-zA-Z0-9_-]+/g); + +type HeaderEntry = [id: string, name: string, value: string]; + +// XXX(epurkhiser): The types of the FormField render props are absolutely +// abysmal, so we're leaving this untyped for now. + +function UptimHeadersControl(props: any) { + const {onChange, onBlur, disabled, model, name, value} = props; + + // Store itmes in local state so we can add empty values without persisting + // those into the form model. + const [items, setItems] = useState(() => + Object.keys(value).length > 0 + ? value.map((v: any) => [uniqueId(), ...v] as HeaderEntry) + : [[uniqueId(), '', '']] + ); + + // Persist the field value back to the form model on changes to the items + // list. Empty items are discarded and not persisted. + useEffect(() => { + const newValue = items.filter(item => item[1] !== '').map(item => [item[1], item[2]]); + + onChange(newValue, {}); + onBlur(newValue, {}); + }, [items, onChange, onBlur]); + + function addItem() { + setItems(currentItems => [...currentItems, [uniqueId(), '', '']]); + } + + function removeItem(index: number) { + setItems(currentItems => currentItems.toSpliced(index, 1)); + } + + function handleNameChange(index: number, newName: string) { + setItems(currentItems => + currentItems.toSpliced(index, 1, [ + items[index]![0], + newName.replaceAll(INVALID_NAME_HEADER_REGEX, ''), + items[index]![2], + ]) + ); + } + + function handleValueChange(index: number, newHeaderValue: string) { + setItems(currentItems => + currentItems.toSpliced(index, 1, [ + items[index]![0], + items[index]![1], + newHeaderValue, + ]) + ); + } + + /** + * Disambiguates headers that are named the same by adding a `(x)` number to + * the end of the name in the order they were added. + */ + function disambiguateHeaderName(index: number) { + const headerName = items[index]![1]; + const matchingIndexes = items + .map((item, idx) => [idx, item[1]]) + .filter(([_, itemName]) => itemName === headerName) + .map(([idx]) => idx); + + const duplicateIndex = matchingIndexes.indexOf(index) + 1; + + return duplicateIndex === 1 ? headerName : `${headerName} (${duplicateIndex})`; + } + + return ( + + {items.length > 0 && ( + + {items.map(([id, headerName, headerValue], index) => ( + + handleNameChange(index, e.target.value)} + aria-label={t('Name of header %s', index + 1)} + /> + handleValueChange(index, e.target.value)} + aria-label={ + headerName + ? t('Value of %s', disambiguateHeaderName(index)) + : t('Value of header %s', index + 1) + } + /> + + + + + ); +} + +export function UptimeHeadersField(props: Omit) { + return ( + + {({ref: _ref, ...fieldProps}) => } + + ); +} + +const HeadersContainer = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1)}; +`; + +const HeaderActions = styled('div')` + display: flex; + gap: ${space(1.5)}; +`; + +const HeaderItems = styled('fieldset')` + display: grid; + grid-template-columns: minmax(200px, 1fr) 2fr max-content; + gap: ${space(1)}; + width: 100%; +`; + +const HeaderRow = styled('div')` + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; +`; diff --git a/static/app/views/detectors/components/forms/uptime/fields.tsx b/static/app/views/detectors/components/forms/uptime/fields.tsx new file mode 100644 index 00000000000000..8f4d4aa8820764 --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/fields.tsx @@ -0,0 +1,42 @@ +import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; + +interface UptimeDetectorFormData { + environment: string; + intervalSeconds: number; + method: string; + name: string; + owner: string; + projectId: string; + timeoutMs: number; + traceSampling: boolean; + url: string; +} + +type UptimeDetectorFormFieldName = keyof UptimeDetectorFormData; + +/** + * Small helper to automatically get the type of the form field. + */ +export function useUptimeDetectorFormField( + name: T +): UptimeDetectorFormData[T] { + const value = useFormField(name); + return value; +} + +/** + * Enables type-safe form field names. + * Helps you find areas setting specific fields. + */ +export const UPTIME_DETECTOR_FORM_FIELDS = { + // Core detector fields + name: 'name', + environment: 'environment', + projectId: 'projectId', + owner: 'owner', + intervalSeconds: 'intervalSeconds', + timeoutMs: 'timeoutMs', + url: 'url', + method: 'method', + traceSampling: 'traceSampling', +} satisfies Record; diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index ac93f186d20dbf..e068bc7c2b37c1 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -6,6 +6,10 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {AssigneeField} from 'sentry/views/detectors/components/forms/assigneeField'; import {UptimeDetectorFormDetectSection} from 'sentry/views/detectors/components/forms/uptime/detect'; +import { + UPTIME_DETECTOR_FORM_FIELDS, + useUptimeDetectorFormField, +} from 'sentry/views/detectors/components/forms/uptime/fields'; export function UptimeDetectorForm() { return ( @@ -17,11 +21,11 @@ export function UptimeDetectorForm() { } function AssignSection() { + const projectId = useUptimeDetectorFormField(UPTIME_DETECTOR_FORM_FIELDS.projectId); return (
- {/* TODO: Add projectId from form context */} - +
);