Browse Source

feat(alerts): Alert wizard v3 project selector dropdown (#33349)

* feat(alerts): Alert wizard v3 project selector dropdown

* select container value

* final metric project dropdown

* issue alert project change dropdown

* add action interval help text

* remove with router
Taylan Gocmen 2 years ago
parent
commit
b5f44257e8

+ 1 - 1
static/app/views/alerts/builder/builderBreadCrumbs.tsx

@@ -91,7 +91,7 @@ function BuilderBreadCrumbs({
       label: t('Alerts'),
       preservePageFilters: true,
     },
-    projectCrumb,
+    ...(hasAlertWizardV3 ? [] : [projectCrumb]),
     {
       label: title,
       ...(alertName

+ 83 - 10
static/app/views/alerts/incidentRules/ruleConditionsForm.tsx

@@ -1,6 +1,10 @@
 import * as React from 'react';
 import {Fragment} from 'react';
+import {InjectedRouter} from 'react-router';
+import {components} from 'react-select';
+import {css} from '@emotion/react';
 import styled from '@emotion/styled';
+import {Location} from 'history';
 import pick from 'lodash/pick';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
@@ -10,15 +14,17 @@ import SearchBar from 'sentry/components/events/searchBar';
 import FormField from 'sentry/components/forms/formField';
 import SelectControl from 'sentry/components/forms/selectControl';
 import SelectField from 'sentry/components/forms/selectField';
+import IdBadge from 'sentry/components/idBadge';
 import ListItem from 'sentry/components/list/listItem';
 import {Panel, PanelBody} from 'sentry/components/panels';
 import Tooltip from 'sentry/components/tooltip';
 import {IconQuestion} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Environment, Organization, SelectValue} from 'sentry/types';
+import {Environment, Organization, Project, SelectValue} from 'sentry/types';
 import {MobileVital, WebVital} from 'sentry/utils/discover/fields';
 import {getDisplayName} from 'sentry/utils/environment';
+import withProjects from 'sentry/utils/withProjects';
 import WizardField from 'sentry/views/alerts/incidentRules/wizardField';
 import {
   convertDatasetEventTypesToSource,
@@ -55,15 +61,19 @@ type Props = {
   dataset: Dataset;
   disabled: boolean;
   hasAlertWizardV3: boolean;
+  location: Location;
   onComparisonDeltaChange: (value: number) => void;
   onFilterSearch: (query: string) => void;
   onTimeWindowChange: (value: number) => void;
   organization: Organization;
-  projectSlug: string;
+  project: Project;
+  projects: Project[];
+  router: InjectedRouter;
   thresholdChart: React.ReactNode;
   timeWindow: number;
   allowChangeEventTypes?: boolean;
   comparisonDelta?: number;
+  loadingProjects?: boolean;
 };
 
 type State = {
@@ -85,11 +95,11 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
   };
 
   async fetchData() {
-    const {api, organization, projectSlug} = this.props;
+    const {api, organization, project} = this.props;
 
     try {
       const environments = await api.requestPromise(
-        `/projects/${organization.slug}/${projectSlug}/environments/`,
+        `/projects/${organization.slug}/${project.slug}/environments/`,
         {
           query: {
             visibility: 'visible',
@@ -234,6 +244,57 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
     );
   }
 
+  renderProjectSelector() {
+    const {project: selectedProject, location, router, projects, disabled} = this.props;
+
+    return (
+      <SelectControl
+        isDisabled={disabled}
+        value={selectedProject.id}
+        styles={{
+          container: (provided: {[x: string]: string | number | boolean}) => ({
+            ...provided,
+            margin: `${space(0.5)}`,
+          }),
+        }}
+        options={projects.map(project => ({
+          label: project.slug,
+          value: project.id,
+          leadingItems: (
+            <IdBadge
+              project={project}
+              avatarProps={{consistentWidth: true}}
+              avatarSize={18}
+              disableLink
+              hideName
+            />
+          ),
+        }))}
+        onChange={({label}: {label: Project['slug']}) =>
+          router.replace({
+            ...location,
+            query: {
+              ...location.query,
+              project: label,
+            },
+          })
+        }
+        components={{
+          SingleValue: containerProps => (
+            <components.ValueContainer {...containerProps}>
+              <IdBadge
+                project={selectedProject}
+                avatarProps={{consistentWidth: true}}
+                avatarSize={18}
+                disableLink
+              />
+            </components.ValueContainer>
+          ),
+        }}
+      />
+    );
+  }
+
   renderInterval() {
     const {
       organization,
@@ -275,8 +336,6 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
               disabled={disabled}
               style={{
                 ...this.formElemBaseStyle,
-                padding: 0,
-                marginRight: space(1),
                 flex: 1,
               }}
               inline={false}
@@ -312,6 +371,10 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
                 minWidth: hasAlertWizardV3 ? 200 : 130,
                 maxWidth: 300,
               }),
+              container: (provided: {[x: string]: string | number | boolean}) => ({
+                ...provided,
+                margin: hasAlertWizardV3 ? `${space(0.5)}` : 0,
+              }),
             }}
             options={this.timeWindowOptions}
             required
@@ -396,8 +459,12 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
           <StyledPanelBody>{this.props.thresholdChart}</StyledPanelBody>
         </ChartPanel>
         {hasAlertWizardV3 && this.renderInterval()}
-        <StyledListItem>{t('Filter environments')}</StyledListItem>
-        <FormRow noMargin>
+        <StyledListItem>{t('Filter events')}</StyledListItem>
+        <FormRow
+          noMargin
+          columns={1 + (allowChangeEventTypes ? 1 : 0) + (hasAlertWizardV3 ? 1 : 0)}
+        >
+          {hasAlertWizardV3 && this.renderProjectSelector()}
           <SelectField
             name="environment"
             placeholder={t('All')}
@@ -520,12 +587,18 @@ const StyledListItem = styled(ListItem)`
   line-height: 1.3;
 `;
 
-const FormRow = styled('div')<{noMargin?: boolean}>`
+const FormRow = styled('div')<{columns?: number; noMargin?: boolean}>`
   display: flex;
   flex-direction: row;
   align-items: center;
   flex-wrap: wrap;
   margin-bottom: ${p => (p.noMargin ? 0 : space(4))};
+  ${p =>
+    p.columns !== undefined &&
+    css`
+      display: grid;
+      grid-template-columns: repeat(${p.columns}, auto);
+    `}
 `;
 
 const FormRowText = styled('div')`
@@ -539,4 +612,4 @@ const ComparisonContainer = styled('div')`
   align-items: center;
 `;
 
-export default RuleConditionsForm;
+export default withProjects(RuleConditionsForm);

+ 5 - 1
static/app/views/alerts/incidentRules/ruleForm/index.tsx

@@ -663,6 +663,8 @@ class RuleFormContainer extends AsyncComponent<Props, State> {
       project,
       userTeamIds,
       isCustomMetric,
+      router,
+      location,
     } = this.props;
     const {
       query,
@@ -813,8 +815,10 @@ class RuleFormContainer extends AsyncComponent<Props, State> {
             <List symbol="colored-numeric">
               <RuleConditionsForm
                 api={this.api}
-                projectSlug={project.slug}
+                project={project}
                 organization={organization}
+                router={router}
+                location={location}
                 disabled={!hasAccess || !canEdit}
                 thresholdChart={wizardBuilderChart}
                 onFilterSearch={this.handleFilterUpdate}

+ 3 - 9
static/app/views/alerts/incidentRules/wizardField.tsx

@@ -22,7 +22,7 @@ import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
 
 import {getFieldOptionConfig} from './metricField';
 
-type MenuOption = {label: string; value: any};
+type MenuOption = {label: string; value: AlertType};
 
 type Props = Omit<FormField['props'], 'children'> & {
   location: Location;
@@ -162,15 +162,9 @@ function WizardField({
           <Container hideGap={gridColumns < 1}>
             <SelectControl
               value={selectedTemplate}
-              styles={{
-                container: (provided: {[x: string]: string | number | boolean}) => ({
-                  ...provided,
-                  margin: `${space(0.5)}`,
-                }),
-              }}
               options={menuOptions}
-              onChange={args => {
-                const template = AlertWizardRuleTemplates[args.value];
+              onChange={(option: MenuOption) => {
+                const template = AlertWizardRuleTemplates[option.value];
 
                 model.setValue('aggregate', template.aggregate);
                 model.setValue('dataset', template.dataset);

+ 230 - 66
static/app/views/alerts/issueRuleEditor/index.tsx

@@ -1,5 +1,6 @@
-import * as React from 'react';
+import {ChangeEvent, Fragment, ReactNode} from 'react';
 import {browserHistory, RouteComponentProps} from 'react-router';
+import {components} from 'react-select';
 import styled from '@emotion/styled';
 import classNames from 'classnames';
 import cloneDeep from 'lodash/cloneDeep';
@@ -19,9 +20,12 @@ import Button from 'sentry/components/button';
 import Confirm from 'sentry/components/confirm';
 import Input from 'sentry/components/forms/controls/input';
 import Field from 'sentry/components/forms/field';
+import FieldHelp from 'sentry/components/forms/field/fieldHelp';
 import Form from 'sentry/components/forms/form';
+import SelectControl from 'sentry/components/forms/selectControl';
 import SelectField from 'sentry/components/forms/selectField';
 import TeamSelector from 'sentry/components/forms/teamSelector';
+import IdBadge from 'sentry/components/idBadge';
 import List from 'sentry/components/list';
 import ListItem from 'sentry/components/list/listItem';
 import LoadingMask from 'sentry/components/loadingMask';
@@ -45,6 +49,7 @@ import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
 import recreateRoute from 'sentry/utils/recreateRoute';
 import routeTitleGen from 'sentry/utils/routeTitle';
 import withOrganization from 'sentry/utils/withOrganization';
+import withProjects from 'sentry/utils/withProjects';
 import {
   CHANGE_ALERT_CONDITION_IDS,
   CHANGE_ALERT_PLACEHOLDERS_LABELS,
@@ -103,7 +108,9 @@ type RouteParams = {orgId: string; projectId?: string; ruleId?: string};
 type Props = {
   organization: Organization;
   project: Project;
+  projects: Project[];
   userTeamIds: string[];
+  loadingProjects?: boolean;
   onChangeTitle?: (data: string) => void;
 } & RouteComponentProps<RouteParams, {}>;
 
@@ -278,7 +285,7 @@ class IssueRuleEditor extends AsyncView<Props, State> {
     addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
   };
 
-  handleRuleSaveFailure(msg: React.ReactNode) {
+  handleRuleSaveFailure(msg: ReactNode) {
     addErrorMessage(msg);
     metric.endTransaction({name: 'saveAlertRule'});
   }
@@ -574,9 +581,94 @@ class IssueRuleEditor extends AsyncView<Props, State> {
     );
   }
 
+  renderRuleName(hasAccess: boolean, canEdit: boolean, hasAlertWizardV3: boolean) {
+    const {rule, detailedError} = this.state;
+    const {name} = rule || {};
+
+    return (
+      <StyledField
+        hasAlertWizardV3={hasAlertWizardV3}
+        label={hasAlertWizardV3 ? null : t('Alert name')}
+        help={hasAlertWizardV3 ? null : t('Add a name for this alert')}
+        error={detailedError?.name?.[0]}
+        disabled={!hasAccess || !canEdit}
+        required
+        stacked
+        flexibleControlStateSize={hasAlertWizardV3 ? true : undefined}
+      >
+        <Input
+          type="text"
+          name="name"
+          value={name}
+          placeholder={hasAlertWizardV3 ? t('Enter Alert Name') : t('My Rule Name')}
+          onChange={(event: ChangeEvent<HTMLInputElement>) =>
+            this.handleChange('name', event.target.value)
+          }
+          onBlur={this.handleValidateRuleName}
+          disabled={!hasAccess || !canEdit}
+        />
+      </StyledField>
+    );
+  }
+
+  renderTeamSelect(hasAccess: boolean, canEdit: boolean, hasAlertWizardV3: boolean) {
+    const {project} = this.props;
+    const {rule} = this.state;
+    const ownerId = rule?.owner?.split(':')[1];
+
+    return (
+      <StyledField
+        hasAlertWizardV3={hasAlertWizardV3}
+        label={hasAlertWizardV3 ? null : t('Team')}
+        help={hasAlertWizardV3 ? null : t('The team that can edit this alert.')}
+        disabled={!hasAccess || !canEdit}
+        flexibleControlStateSize={hasAlertWizardV3 ? true : undefined}
+      >
+        <TeamSelector
+          value={this.getTeamId()}
+          project={project}
+          onChange={this.handleOwnerChange}
+          teamFilter={(team: Team) => team.isMember || team.id === ownerId}
+          useId
+          includeUnassigned
+          disabled={!hasAccess || !canEdit}
+        />
+      </StyledField>
+    );
+  }
+
+  renderActionInterval(hasAccess: boolean, canEdit: boolean, hasAlertWizardV3: boolean) {
+    const {rule} = this.state;
+    const {frequency} = rule || {};
+
+    return (
+      <StyledSelectField
+        hasAlertWizardV3={hasAlertWizardV3}
+        label={hasAlertWizardV3 ? null : t('Action Interval')}
+        help={
+          hasAlertWizardV3
+            ? null
+            : t('Perform these actions once this often for an issue')
+        }
+        clearable={false}
+        name="frequency"
+        className={this.hasError('frequency') ? ' error' : ''}
+        value={frequency}
+        required
+        options={FREQUENCY_OPTIONS}
+        onChange={val => this.handleChange('frequency', val)}
+        disabled={!hasAccess || !canEdit}
+        flexibleControlStateSize={hasAlertWizardV3 ? true : undefined}
+      />
+    );
+  }
+
   renderBody() {
-    const {project, organization, userTeamIds} = this.props;
-    const {environments} = this.state;
+    const {project, organization, userTeamIds, location, router, projects} = this.props;
+    const {environments, rule, detailedError} = this.state;
+    const {actions, filters, conditions, frequency} = rule || {};
+    const hasAlertWizardV3 = organization.features.includes('alert-wizard-v3');
+
     const environmentOptions = [
       {
         value: ALL_ENVIRONMENTS_KEY,
@@ -586,9 +678,6 @@ class IssueRuleEditor extends AsyncView<Props, State> {
         []),
     ];
 
-    const {rule, detailedError} = this.state;
-    const {actions, filters, conditions, frequency, name} = rule || {};
-
     const environment =
       !rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
 
@@ -634,60 +723,90 @@ class IssueRuleEditor extends AsyncView<Props, State> {
             <List symbol="colored-numeric">
               {this.state.loading && <SemiTransparentLoadingMask />}
               <StyledListItem>{t('Add alert settings')}</StyledListItem>
-              <Panel>
-                <PanelBody>
-                  <SelectField
+              {hasAlertWizardV3 ? (
+                <SettingsContainer>
+                  <StyledSelectField
+                    hasAlertWizardV3={hasAlertWizardV3}
                     className={classNames({
                       error: this.hasError('environment'),
                     })}
-                    label={t('Environment')}
-                    help={t('Choose an environment for these conditions to apply to')}
                     placeholder={t('Select an Environment')}
                     clearable={false}
                     name="environment"
                     options={environmentOptions}
                     onChange={val => this.handleEnvironmentChange(val)}
                     disabled={!hasAccess || !canEdit}
+                    flexibleControlStateSize
                   />
-
-                  <StyledField
-                    label={t('Team')}
-                    help={t('The team that can edit this alert.')}
+                  <SelectControl
                     disabled={!hasAccess || !canEdit}
-                  >
-                    <TeamSelector
-                      value={this.getTeamId()}
-                      project={project}
-                      onChange={this.handleOwnerChange}
-                      teamFilter={(team: Team) => team.isMember || team.id === ownerId}
-                      useId
-                      includeUnassigned
+                    value={project.id}
+                    styles={{
+                      container: (provided: {
+                        [x: string]: string | number | boolean;
+                      }) => ({
+                        ...provided,
+                        marginBottom: `${space(1)}`,
+                      }),
+                    }}
+                    options={projects.map(_project => ({
+                      label: _project.slug,
+                      value: _project.id,
+                      leadingItems: (
+                        <IdBadge
+                          project={_project}
+                          avatarProps={{consistentWidth: true}}
+                          avatarSize={18}
+                          disableLink
+                          hideName
+                        />
+                      ),
+                    }))}
+                    onChange={({label}: {label: Project['slug']}) =>
+                      router.replace({
+                        ...location,
+                        query: {
+                          ...location.query,
+                          project: label,
+                        },
+                      })
+                    }
+                    components={{
+                      SingleValue: containerProps => (
+                        <components.ValueContainer {...containerProps}>
+                          <IdBadge
+                            project={project}
+                            avatarProps={{consistentWidth: true}}
+                            avatarSize={18}
+                            disableLink
+                          />
+                        </components.ValueContainer>
+                      ),
+                    }}
+                  />
+                </SettingsContainer>
+              ) : (
+                <Panel>
+                  <PanelBody>
+                    <SelectField
+                      className={classNames({
+                        error: this.hasError('environment'),
+                      })}
+                      label={t('Environment')}
+                      help={t('Choose an environment for these conditions to apply to')}
+                      placeholder={t('Select an Environment')}
+                      clearable={false}
+                      name="environment"
+                      options={environmentOptions}
+                      onChange={val => this.handleEnvironmentChange(val)}
                       disabled={!hasAccess || !canEdit}
                     />
-                  </StyledField>
 
-                  <StyledField
-                    label={t('Alert name')}
-                    help={t('Add a name for this alert')}
-                    error={detailedError?.name?.[0]}
-                    disabled={!hasAccess || !canEdit}
-                    required
-                    stacked
-                  >
-                    <Input
-                      type="text"
-                      name="name"
-                      value={name}
-                      placeholder={t('My Rule Name')}
-                      onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
-                        this.handleChange('name', event.target.value)
-                      }
-                      onBlur={this.handleValidateRuleName}
-                      disabled={!hasAccess || !canEdit}
-                    />
-                  </StyledField>
-                </PanelBody>
-              </Panel>
+                    {this.renderTeamSelect(hasAccess, canEdit, hasAlertWizardV3)}
+                    {this.renderRuleName(hasAccess, canEdit, hasAlertWizardV3)}
+                  </PanelBody>
+                </Panel>
+              )}
               <SetConditionsListItem>
                 {t('Set conditions')}
                 <SetupAlertIntegrationButton
@@ -900,23 +1019,28 @@ class IssueRuleEditor extends AsyncView<Props, State> {
                   </Step>
                 </PanelBody>
               </ConditionsPanel>
-              <StyledListItem>{t('Set action interval')}</StyledListItem>
-              <Panel>
-                <PanelBody>
-                  <SelectField
-                    label={t('Action Interval')}
-                    help={t('Perform these actions once this often for an issue')}
-                    clearable={false}
-                    name="frequency"
-                    className={this.hasError('frequency') ? ' error' : ''}
-                    value={frequency}
-                    required
-                    options={FREQUENCY_OPTIONS}
-                    onChange={val => this.handleChange('frequency', val)}
-                    disabled={!hasAccess || !canEdit}
-                  />
-                </PanelBody>
-              </Panel>
+              <StyledListItem>
+                {t('Set action interval')}
+                <StyledFieldHelp>
+                  {t('Perform the actions above once this often for an issue')}
+                </StyledFieldHelp>
+              </StyledListItem>
+              {hasAlertWizardV3 ? (
+                this.renderActionInterval(hasAccess, canEdit, hasAlertWizardV3)
+              ) : (
+                <Panel>
+                  <PanelBody>
+                    {this.renderActionInterval(hasAccess, canEdit, hasAlertWizardV3)}
+                  </PanelBody>
+                </Panel>
+              )}
+              {hasAlertWizardV3 && (
+                <Fragment>
+                  <StyledListItem>{t('Establish ownership')}</StyledListItem>
+                  {this.renderRuleName(hasAccess, canEdit, hasAlertWizardV3)}
+                  {this.renderTeamSelect(hasAccess, canEdit, hasAlertWizardV3)}
+                </Fragment>
+              )}
             </List>
           </StyledForm>
         )}
@@ -925,7 +1049,7 @@ class IssueRuleEditor extends AsyncView<Props, State> {
   }
 }
 
-export default withOrganization(IssueRuleEditor);
+export default withOrganization(withProjects(IssueRuleEditor));
 
 // TODO(ts): Understand why styled is not correctly inheriting props here
 const StyledForm = styled(Form)<Form['props']>`
@@ -946,6 +1070,10 @@ const StyledListItem = styled(ListItem)`
   font-size: ${p => p.theme.fontSizeExtraLarge};
 `;
 
+const StyledFieldHelp = styled(FieldHelp)`
+  margin-top: 0;
+`;
+
 const SetConditionsListItem = styled(StyledListItem)`
   display: flex;
   justify-content: space-between;
@@ -1018,8 +1146,44 @@ const SemiTransparentLoadingMask = styled(LoadingMask)`
   z-index: 1; /* Needed so that it sits above form elements */
 `;
 
-const StyledField = styled(Field)`
+const SettingsContainer = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: ${space(1)};
+`;
+
+const StyledField = styled(Field)<{hasAlertWizardV3?: boolean}>`
   :last-child {
     padding-bottom: ${space(2)};
   }
+
+  ${p =>
+    p.hasAlertWizardV3 &&
+    `
+    border-bottom: none;
+    padding: 0;
+
+    & > div {
+      padding: 0;
+      width: 100%;
+    }
+
+    margin-bottom: ${space(1)};
+  `}
+`;
+
+const StyledSelectField = styled(SelectField)<{hasAlertWizardV3?: boolean}>`
+  ${p =>
+    p.hasAlertWizardV3 &&
+    `
+    border-bottom: none;
+    padding: 0;
+
+    & > div {
+      padding: 0;
+      width: 100%;
+    }
+
+    margin-bottom: ${space(1)};
+  `}
 `;