Browse Source

feat(crons): Add new monitor manual creation flow (#58712)

Adds this new form behind the `crons-new-monitor-form`


![image](https://github.com/getsentry/sentry/assets/9372512/a20bd4df-2c43-46a9-837b-8aafee2a877c)

The goal here being to simplify monitor creation, and then offer the
rest of configuration options (in the edit monitor flow) after the user
has created the monitor.

This form also still needs a "skeleton" timeline view to show an example
schedule
David Wang 1 year ago
parent
commit
4c72698baf

+ 13 - 6
static/app/views/monitors/components/cronsLandingPanel.tsx

@@ -14,6 +14,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import MonitorCreateForm from 'sentry/views/monitors/components/monitorCreateForm';
 import MonitorForm from 'sentry/views/monitors/components/monitorForm';
 import {Monitor} from 'sentry/views/monitors/types';
 
@@ -162,6 +163,8 @@ export function CronsLandingPanel() {
     browserHistory.push(url);
   }
 
+  const hasNewOnboarding = organization.features.includes('crons-new-monitor-form');
+
   return (
     <Panel>
       <BackButton
@@ -196,12 +199,16 @@ export function CronsLandingPanel() {
               )),
               <TabPanels.Item key={GuideKey.MANUAL}>
                 <GuideContainer>
-                  <MonitorForm
-                    apiMethod="POST"
-                    apiEndpoint={`/organizations/${organization.slug}/monitors/`}
-                    onSubmitSuccess={onCreateMonitor}
-                    submitLabel={t('Next')}
-                  />
+                  {hasNewOnboarding ? (
+                    <MonitorCreateForm />
+                  ) : (
+                    <MonitorForm
+                      apiMethod="POST"
+                      apiEndpoint={`/organizations/${organization.slug}/monitors/`}
+                      onSubmitSuccess={onCreateMonitor}
+                      submitLabel={t('Next')}
+                    />
+                  )}
                 </GuideContainer>
               </TabPanels.Item>,
             ]}

+ 242 - 0
static/app/views/monitors/components/monitorCreateForm.tsx

@@ -0,0 +1,242 @@
+import {Fragment, useRef} from 'react';
+import {browserHistory} from 'react-router';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {Observer} from 'mobx-react';
+
+import NumberField from 'sentry/components/forms/fields/numberField';
+import SelectField from 'sentry/components/forms/fields/selectField';
+import SentryProjectSelectorField from 'sentry/components/forms/fields/sentryProjectSelectorField';
+import TextField from 'sentry/components/forms/fields/textField';
+import Form from 'sentry/components/forms/form';
+import FormModel from 'sentry/components/forms/model';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import {timezoneOptions} from 'sentry/data/timezones';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
+import commonTheme from 'sentry/utils/theme';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {
+  DEFAULT_CRONTAB,
+  DEFAULT_MONITOR_TYPE,
+  mapMonitorFormErrors,
+  transformMonitorFormData,
+} from 'sentry/views/monitors/components/monitorForm';
+import {Monitor, ScheduleType} from 'sentry/views/monitors/types';
+import {crontabAsText, getScheduleIntervals} from 'sentry/views/monitors/utils';
+
+export default function MonitorCreateForm() {
+  const organization = useOrganization();
+  const {projects} = useProjects();
+  const {selection} = usePageFilters();
+
+  const form = useRef(
+    new FormModel({
+      transformData: transformMonitorFormData,
+      mapFormErrors: mapMonitorFormErrors,
+    })
+  );
+
+  const selectedProjectId = selection.projects[0];
+  const selectedProject = selectedProjectId
+    ? projects.find(p => p.id === selectedProjectId + '')
+    : null;
+
+  const isSuperuser = isActiveSuperuser();
+  const filteredProjects = projects.filter(project => isSuperuser || project.isMember);
+
+  function onCreateMonitor(data: Monitor) {
+    const url = normalizeUrl(`/organizations/${organization.slug}/crons/${data.slug}/`);
+    browserHistory.push(url);
+  }
+
+  function changeScheduleType(type: ScheduleType) {
+    form.current.setValue('config.schedule_type', type);
+  }
+
+  return (
+    <Form
+      allowUndo
+      requireChanges
+      apiEndpoint={`/organizations/${organization.slug}/monitors/`}
+      apiMethod="POST"
+      model={form.current}
+      initialData={{
+        project: selectedProject ? selectedProject.slug : null,
+        type: DEFAULT_MONITOR_TYPE,
+        'config.schedule_type': ScheduleType.CRONTAB,
+      }}
+      onSubmitSuccess={onCreateMonitor}
+      submitLabel={t('Next')}
+    >
+      <FieldContainer>
+        <MultiColumnInput columns="250px 1fr">
+          <StyledSentryProjectSelectorField
+            name="project"
+            projects={filteredProjects}
+            placeholder={t('Choose Project')}
+            disabledReason={t('Existing monitors cannot be moved between projects')}
+            valueIsSlug
+            required
+            stacked
+            inline={false}
+          />
+          <StyledTextField
+            name="name"
+            placeholder={t('My Cron Job')}
+            required
+            stacked
+            inline={false}
+          />
+        </MultiColumnInput>
+        <LabelText>{t('SCHEDULE')}</LabelText>
+        <ScheduleConfig>
+          <Observer>
+            {() => {
+              const scheduleType = form.current.getValue('config.schedule_type');
+              const parsedSchedule = crontabAsText(
+                form.current.getValue('config.schedule')?.toString() ?? ''
+              );
+              return (
+                <Fragment>
+                  <SchedulePanel
+                    highlighted={scheduleType === ScheduleType.CRONTAB}
+                    onClick={() => changeScheduleType(ScheduleType.CRONTAB)}
+                  >
+                    <PanelBody withPadding>
+                      <ScheduleLabel>{t('Crontab Schedule')}</ScheduleLabel>
+                      <MultiColumnInput columns="1fr 1fr">
+                        <StyledTextField
+                          name="config.schedule"
+                          placeholder="* * * * *"
+                          defaultValue={DEFAULT_CRONTAB}
+                          css={{input: {fontFamily: commonTheme.text.familyMono}}}
+                          required={scheduleType === ScheduleType.CRONTAB}
+                          stacked
+                          inline={false}
+                        />
+                        <StyledSelectField
+                          name="config.timezone"
+                          defaultValue="UTC"
+                          options={timezoneOptions}
+                          required={scheduleType === ScheduleType.CRONTAB}
+                          stacked
+                          inline={false}
+                        />
+                        <CronstrueText>{parsedSchedule}</CronstrueText>
+                      </MultiColumnInput>
+                    </PanelBody>
+                  </SchedulePanel>
+                  <SchedulePanel
+                    highlighted={scheduleType === ScheduleType.INTERVAL}
+                    onClick={() => changeScheduleType(ScheduleType.INTERVAL)}
+                  >
+                    <PanelBody withPadding>
+                      <ScheduleLabel>{t('Interval Schedule')}</ScheduleLabel>
+                      <MultiColumnInput columns="auto 1fr 2fr">
+                        <Label>{t('Every')}</Label>
+                        <StyledNumberField
+                          name="config.schedule.frequency"
+                          placeholder="e.g. 1"
+                          defaultValue="1"
+                          required={scheduleType === ScheduleType.INTERVAL}
+                          stacked
+                          inline={false}
+                        />
+                        <StyledSelectField
+                          name="config.schedule.interval"
+                          options={getScheduleIntervals(
+                            Number(
+                              form.current.getValue('config.schedule.frequency') ?? 1
+                            )
+                          )}
+                          defaultValue="day"
+                          required={scheduleType === ScheduleType.INTERVAL}
+                          stacked
+                          inline={false}
+                        />
+                      </MultiColumnInput>
+                    </PanelBody>
+                  </SchedulePanel>
+                </Fragment>
+              );
+            }}
+          </Observer>
+        </ScheduleConfig>
+      </FieldContainer>
+    </Form>
+  );
+}
+
+const FieldContainer = styled('div')`
+  width: 800px;
+`;
+
+const SchedulePanel = styled(Panel)<{highlighted: boolean}>`
+  border-radius: 0 ${space(0.75)} ${space(0.75)} 0;
+
+  ${p =>
+    p.highlighted &&
+    css`
+      border: 2px solid ${p.theme.purple300};
+    `};
+
+  &:first-child {
+    border-radius: ${space(0.75)} 0 0 ${space(0.75)};
+  }
+`;
+
+const ScheduleLabel = styled('div')`
+  font-weight: bold;
+  margin-bottom: ${space(2)};
+`;
+
+const Label = styled('div')`
+  font-weight: bold;
+  color: ${p => p.theme.subText};
+`;
+
+const LabelText = styled(Label)`
+  margin-top: ${space(2)};
+  margin-bottom: ${space(1)};
+`;
+
+const ScheduleConfig = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+`;
+
+const MultiColumnInput = styled('div')<{columns?: string}>`
+  display: grid;
+  align-items: center;
+  gap: ${space(1)};
+  grid-template-columns: ${p => p.columns};
+`;
+
+const CronstrueText = styled(LabelText)`
+  font-weight: normal;
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  font-family: ${p => p.theme.text.familyMono};
+  grid-column: auto / span 2;
+`;
+
+const StyledNumberField = styled(NumberField)`
+  padding: 0;
+`;
+
+const StyledSelectField = styled(SelectField)`
+  padding: 0;
+`;
+
+const StyledTextField = styled(TextField)`
+  padding: 0;
+`;
+
+const StyledSentryProjectSelectorField = styled(SentryProjectSelectorField)`
+  padding: 0;
+`;

+ 25 - 17
static/app/views/monitors/components/monitorForm.tsx

@@ -28,7 +28,7 @@ import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjects from 'sentry/utils/useProjects';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
-import {crontabAsText} from 'sentry/views/monitors/utils';
+import {crontabAsText, getScheduleIntervals} from 'sentry/views/monitors/utils';
 
 import {
   IntervalConfig,
@@ -43,8 +43,8 @@ const SCHEDULE_OPTIONS: SelectValue<string>[] = [
   {value: ScheduleType.INTERVAL, label: t('Interval')},
 ];
 
-const DEFAULT_MONITOR_TYPE = 'cron_job';
-const DEFAULT_CRONTAB = '0 0 * * *';
+export const DEFAULT_MONITOR_TYPE = 'cron_job';
+export const DEFAULT_CRONTAB = '0 0 * * *';
 
 // Maps the value from the SentryMemberTeamSelectorField -> the expected alert
 // rule key and vice-versa.
@@ -60,15 +60,6 @@ export const DEFAULT_CHECKIN_MARGIN = 1;
 const CHECKIN_MARGIN_MINIMUM = 1;
 const TIMEOUT_MINIMUM = 1;
 
-const getIntervals = (n: number): SelectValue<string>[] => [
-  {value: 'minute', label: tn('minute', 'minutes', n)},
-  {value: 'hour', label: tn('hour', 'hours', n)},
-  {value: 'day', label: tn('day', 'days', n)},
-  {value: 'week', label: tn('week', 'weeks', n)},
-  {value: 'month', label: tn('month', 'months', n)},
-  {value: 'year', label: tn('year', 'years', n)},
-];
-
 type Props = {
   apiEndpoint: string;
   apiMethod: FormProps['apiMethod'];
@@ -85,8 +76,20 @@ interface TransformedData extends Partial<Omit<Monitor, 'config' | 'alertRule'>>
 /**
  * Transform sub-fields for what the API expects
  */
-function transformData(_data: Record<string, any>, model: FormModel) {
-  const result = model.fields.toJSON().reduce<TransformedData>((data, [k, v]) => {
+export function transformMonitorFormData(_data: Record<string, any>, model: FormModel) {
+  const schedType = model.getValue('config.schedule_type');
+  // Remove interval fields if the monitor schedule is crontab
+  const filteredFields = model.fields
+    .toJSON()
+    .filter(
+      ([k, _v]) =>
+        (schedType === ScheduleType.CRONTAB &&
+          k !== 'config.schedule.interval' &&
+          k !== 'config.schedule.frequency') ||
+        schedType === ScheduleType.INTERVAL
+    );
+
+  const result = filteredFields.reduce<TransformedData>((data, [k, v]) => {
     data.config ??= {};
     data.alertRule ??= {};
 
@@ -146,7 +149,7 @@ function transformData(_data: Record<string, any>, model: FormModel) {
 /**
  * Transform config field errors from the error response
  */
-function mapFormErrors(responseJson?: any) {
+export function mapMonitorFormErrors(responseJson?: any) {
   if (responseJson.config === undefined) {
     return responseJson;
   }
@@ -167,7 +170,12 @@ function MonitorForm({
   apiMethod,
   onSubmitSuccess,
 }: Props) {
-  const form = useRef(new FormModel({transformData, mapFormErrors}));
+  const form = useRef(
+    new FormModel({
+      transformData: transformMonitorFormData,
+      mapFormErrors: mapMonitorFormErrors,
+    })
+  );
   const organization = useOrganization();
   const {projects} = useProjects();
   const {selection} = usePageFilters();
@@ -356,7 +364,7 @@ function MonitorForm({
                     />
                     <StyledSelectField
                       name="config.schedule.interval"
-                      options={getIntervals(
+                      options={getScheduleIntervals(
                         Number(form.current.getValue('config.schedule.frequency') ?? 1)
                       )}
                       defaultValue="day"

+ 10 - 1
static/app/views/monitors/utils.tsx

@@ -3,7 +3,7 @@ import cronstrue from 'cronstrue';
 import {Location} from 'history';
 
 import {t, tn} from 'sentry/locale';
-import {Organization} from 'sentry/types';
+import {Organization, SelectValue} from 'sentry/types';
 import {shouldUse24Hours} from 'sentry/utils/dates';
 import {CheckInStatus, MonitorConfig, ScheduleType} from 'sentry/views/monitors/types';
 
@@ -84,3 +84,12 @@ export function getColorsFromStatus(status: CheckInStatus, theme: Theme) {
   };
   return statusToColor[status];
 }
+
+export const getScheduleIntervals = (n: number): SelectValue<string>[] => [
+  {value: 'minute', label: tn('minute', 'minutes', n)},
+  {value: 'hour', label: tn('hour', 'hours', n)},
+  {value: 'day', label: tn('day', 'days', n)},
+  {value: 'week', label: tn('week', 'weeks', n)},
+  {value: 'month', label: tn('month', 'months', n)},
+  {value: 'year', label: tn('year', 'years', n)},
+];