Browse Source

feat(ui): Implement CRUD actions for Incident Rule Triggers (#14830)

This removes the Trigger row placeholder and renders Triggers based on API response of an Incident Rule.
Implements creating, updating, and deleting triggers as well.

Closes SEN-994
Closes SEN-1052
Billy Vong 5 years ago
parent
commit
3bf22e8451

+ 14 - 1
src/sentry/static/sentry/app/views/settings/incidentRules/actions.tsx

@@ -1,5 +1,5 @@
 import {Client} from 'app/api';
-import {IncidentRule} from './types';
+import {IncidentRule, Trigger} from './types';
 
 export function deleteRule(
   api: Client,
@@ -10,3 +10,16 @@ export function deleteRule(
     method: 'DELETE',
   });
 }
+
+export function deleteTrigger(
+  api: Client,
+  orgId: string,
+  trigger: Trigger
+): Promise<void> {
+  return api.requestPromise(
+    `/organizations/${orgId}/alert-rules/${trigger.alertRuleId}/triggers/${trigger.id}`,
+    {
+      method: 'DELETE',
+    }
+  );
+}

+ 89 - 7
src/sentry/static/sentry/app/views/settings/incidentRules/details.tsx

@@ -1,11 +1,14 @@
 import {RouteComponentProps} from 'react-router/lib/Router';
+import {findIndex} from 'lodash';
 import React from 'react';
 import styled, {css} from 'react-emotion';
 
-import {IncidentRule} from 'app/views/settings/incidentRules/types';
+import {IncidentRule, Trigger} from 'app/views/settings/incidentRules/types';
 import {Organization, Project} from 'app/types';
+import {addErrorMessage} from 'app/actionCreators/indicator';
+import {deleteTrigger} from 'app/views/settings/incidentRules/actions';
 import {openModal} from 'app/actionCreators/modal';
-import {t} from 'app/locale';
+import {t, tct} from 'app/locale';
 import AsyncView from 'app/views/asyncView';
 import Button from 'app/components/button';
 import RuleForm from 'app/views/settings/incidentRules/ruleForm';
@@ -49,23 +52,98 @@ class IncidentRulesDetails extends AsyncView<
     ];
   }
 
-  handleNewTrigger = () => {
+  openTriggersModal = (trigger?: Trigger) => {
     const {organization, projects} = this.props;
 
     openModal(
-      () => (
+      ({closeModal}) => (
         <TriggersModal
           organization={organization}
           projects={projects || []}
           rule={this.state.rule}
+          trigger={trigger}
+          closeModal={closeModal}
+          onSubmitSuccess={trigger ? this.handleEditedTrigger : this.handleAddedTrigger}
         />
       ),
       {dialogClassName: widthCss}
     );
   };
 
+  handleAddedTrigger = (trigger: Trigger) => {
+    this.setState(({rule}) => ({
+      rule: {
+        ...rule,
+        triggers: [...rule.triggers, trigger],
+      },
+    }));
+  };
+
+  handleEditedTrigger = (trigger: Trigger) => {
+    this.setState(({rule}) => {
+      const triggerIndex = findIndex(rule.triggers, ({id}) => id === trigger.id);
+      const triggers = [...rule.triggers];
+      triggers.splice(triggerIndex, 1, trigger);
+
+      return {
+        rule: {
+          ...rule,
+          triggers,
+        },
+      };
+    });
+  };
+
+  handleNewTrigger = () => {
+    this.openTriggersModal();
+  };
+
+  handleEditTrigger = (trigger: Trigger) => {
+    this.openTriggersModal(trigger);
+  };
+
+  handleDeleteTrigger = async (trigger: Trigger) => {
+    const {organization} = this.props;
+
+    // Optimistically update
+    const triggerIndex = findIndex(this.state.rule.triggers, ({id}) => id === trigger.id);
+    const triggersAfterDelete = [...this.state.rule.triggers];
+    triggersAfterDelete.splice(triggerIndex, 1);
+
+    this.setState(({rule}) => {
+      return {
+        rule: {
+          ...rule,
+          triggers: triggersAfterDelete,
+        },
+      };
+    });
+
+    try {
+      await deleteTrigger(this.api, organization.slug, trigger);
+    } catch (err) {
+      addErrorMessage(
+        tct('There was a problem deleting trigger: [label]', {label: trigger.label})
+      );
+
+      // Add trigger back to list
+      this.setState(({rule}) => {
+        const triggers = [...rule.triggers];
+        triggers.splice(triggerIndex, 0, trigger);
+
+        return {
+          rule: {
+            ...rule,
+            triggers,
+          },
+        };
+      });
+    }
+  };
+
   renderBody() {
     const {orgId, incidentRuleId} = this.props.params;
+    const {rule} = this.state;
 
     return (
       <div>
@@ -75,7 +153,7 @@ class IncidentRulesDetails extends AsyncView<
           saveOnBlur={true}
           orgId={orgId}
           incidentRuleId={incidentRuleId}
-          initialData={this.state.rule}
+          initialData={rule}
         />
 
         <TriggersHeader
@@ -85,7 +163,7 @@ class IncidentRulesDetails extends AsyncView<
               size="small"
               priority="primary"
               icon="icon-circle-add"
-              disabled={!this.state.rule}
+              disabled={!rule}
               onClick={this.handleNewTrigger}
             >
               {t('New Trigger')}
@@ -93,7 +171,11 @@ class IncidentRulesDetails extends AsyncView<
           }
         />
 
-        <TriggersList />
+        <TriggersList
+          triggers={rule.triggers}
+          onDelete={this.handleDeleteTrigger}
+          onEdit={this.handleEditTrigger}
+        />
       </div>
     );
   }

+ 20 - 34
src/sentry/static/sentry/app/views/settings/incidentRules/triggers/form.tsx

@@ -17,7 +17,7 @@ import {
   AlertRuleThreshold,
   AlertRuleThresholdType,
   IncidentRule,
-  TimeWindow,
+  Trigger,
 } from '../types';
 
 type AlertRuleThresholdKey = {
@@ -25,21 +25,18 @@ type AlertRuleThresholdKey = {
   [AlertRuleThreshold.RESOLUTION]: 'resolveThreshold';
 };
 
-const DEFAULT_TIME_WINDOW = 60;
-
 type Props = {
   api: Client;
   config: Config;
   organization: Organization;
   projects: Project[];
-  initialData?: IncidentRule;
   rule: IncidentRule;
+  trigger?: Trigger;
 };
 
 type State = {
   width?: number;
   isInverted: boolean;
-  timeWindow: number;
   alertThreshold: number | null;
   resolveThreshold: number | null;
   maxThreshold: number | null;
@@ -53,20 +50,15 @@ class TriggerForm extends React.Component<Props, State> {
   static defaultProps = {};
 
   state = {
-    isInverted: this.props.initialData
-      ? this.props.initialData.thresholdType === AlertRuleThresholdType.BELOW
+    isInverted: this.props.trigger
+      ? this.props.trigger.thresholdType === AlertRuleThresholdType.BELOW
       : false,
-    timeWindow: this.props.initialData
-      ? this.props.initialData.timeWindow
-      : DEFAULT_TIME_WINDOW,
-    alertThreshold: this.props.initialData ? this.props.initialData.alertThreshold : null,
-    resolveThreshold: this.props.initialData
-      ? this.props.initialData.resolveThreshold
-      : null,
-    maxThreshold: this.props.initialData
+    alertThreshold: this.props.trigger ? this.props.trigger.alertThreshold : null,
+    resolveThreshold: this.props.trigger ? this.props.trigger.resolveThreshold : null,
+    maxThreshold: this.props.trigger
       ? Math.max(
-          this.props.initialData.alertThreshold,
-          this.props.initialData.resolveThreshold
+          this.props.trigger.alertThreshold,
+          this.props.trigger.resolveThreshold
         ) || null
       : null,
   };
@@ -182,10 +174,6 @@ class TriggerForm extends React.Component<Props, State> {
     this.updateThreshold(AlertRuleThreshold.RESOLUTION, value);
   };
 
-  handleTimeWindowChange = (timeWindow: TimeWindow) => {
-    this.setState({timeWindow});
-  };
-
   /**
    * Changes the threshold type (i.e. if thresholds are inverted or not)
    */
@@ -209,7 +197,7 @@ class TriggerForm extends React.Component<Props, State> {
 
   render() {
     const {api, config, organization, projects, rule} = this.props;
-    const {alertThreshold, resolveThreshold, isInverted, timeWindow} = this.state;
+    const {alertThreshold, resolveThreshold, isInverted} = this.state;
 
     return (
       <React.Fragment>
@@ -224,14 +212,14 @@ class TriggerForm extends React.Component<Props, State> {
               isInverted={isInverted}
               alertThreshold={alertThreshold}
               resolveThreshold={resolveThreshold}
-              timeWindow={timeWindow}
+              timeWindow={rule.timeWindow}
               onChangeIncidentThreshold={this.handleChangeIncidentThreshold}
               onChangeResolutionThreshold={this.handleChangeResolutionThreshold}
             />
           )}
           fields={[
             {
-              name: 'name',
+              name: 'label',
               type: 'text',
               label: t('Label'),
               help: t('This will prefix alerts created by this trigger'),
@@ -278,34 +266,32 @@ class TriggerForm extends React.Component<Props, State> {
 }
 
 type TriggerFormContainerProps = {
-  initialData?: IncidentRule;
   orgId: string;
-  incidentRuleId?: string;
   onSubmitSuccess?: Function;
 } & React.ComponentProps<typeof TriggerForm>;
 
 function TriggerFormContainer({
   orgId,
-  incidentRuleId,
-  initialData,
   onSubmitSuccess,
+  rule,
+  trigger,
   ...props
 }: TriggerFormContainerProps) {
   return (
     <Form
-      apiMethod={incidentRuleId ? 'PUT' : 'POST'}
-      apiEndpoint={`/organizations/${orgId}/alert-rules/${
-        incidentRuleId ? `${incidentRuleId}/` : ''
+      apiMethod={trigger ? 'PUT' : 'POST'}
+      apiEndpoint={`/organizations/${orgId}/alert-rules/${rule.id}/triggers${
+        trigger ? `/${trigger.id}` : ''
       }`}
       initialData={{
         thresholdType: AlertRuleThresholdType.ABOVE,
-        timeWindow: DEFAULT_TIME_WINDOW,
-        ...initialData,
+        ...trigger,
       }}
       saveOnBlur={false}
       onSubmitSuccess={onSubmitSuccess}
+      submitLabel={trigger ? t('Update Trigger') : t('Create Trigger')}
     >
-      <TriggerForm initialData={initialData} {...props} />
+      <TriggerForm rule={rule} trigger={trigger} {...props} />
     </Form>
   );
 }

+ 87 - 27
src/sentry/static/sentry/app/views/settings/incidentRules/triggers/list.tsx

@@ -1,15 +1,49 @@
 import React from 'react';
 import styled, {css} from 'react-emotion';
 
-import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
+import {Panel, PanelBody, PanelItem, PanelHeader} from 'app/components/panels';
 import {t} from 'app/locale';
 import Button from 'app/components/button';
+import Confirm from 'app/components/confirm';
 import space from 'app/styles/space';
 
-type Props = {};
+import {Trigger, AlertRuleThresholdType} from '../types';
 
+type Props = {
+  triggers: Trigger[];
+  onDelete: (trigger: Trigger) => void;
+  onEdit: (trigger: Trigger) => void;
+};
+
+function getConditionStrings(trigger: Trigger): [string, string | null] {
+  if (trigger.thresholdType === AlertRuleThresholdType.ABOVE) {
+    return [
+      `> ${trigger.alertThreshold}`,
+      typeof trigger.resolveThreshold !== 'undefined' && trigger.resolveThreshold !== null
+        ? `Auto-resolves when metric falls below ${trigger.resolveThreshold}`
+        : null,
+    ];
+  } else {
+    return [
+      `< ${trigger.alertThreshold}`,
+      typeof trigger.resolveThreshold !== 'undefined' && trigger.resolveThreshold !== null
+        ? `Auto-resolves when metric is above ${trigger.resolveThreshold}`
+        : null,
+    ];
+  }
+}
 export default class TriggersList extends React.Component<Props> {
+  handleEdit = (trigger: Trigger) => {
+    this.props.onEdit(trigger);
+  };
+
+  handleDelete = (trigger: Trigger) => {
+    this.props.onDelete(trigger);
+  };
+
   render() {
+    const {triggers} = this.props;
+
     return (
       <Panel>
         <PanelHeaderGrid>
@@ -18,24 +52,47 @@ export default class TriggersList extends React.Component<Props> {
           <div>{t('Actions')}</div>
         </PanelHeaderGrid>
         <PanelBody>
-          <Grid>
-            <Label>SEV-0</Label>
-            <Condition>
-              <MainCondition>1% increase in Error Rate</MainCondition>
-              <SecondaryCondition>
-                Auto-resolves when metric falls below 1%
-              </SecondaryCondition>
-            </Condition>
-            <Actions>
-              <ul>
-                <li>Email members of #team-billing</li>
-              </ul>
-
-              <Button type="default" icon="icon-edit" size="small">
-                Edit
-              </Button>
-            </Actions>
-          </Grid>
+          {triggers.map(trigger => {
+            const [mainCondition, secondaryCondition] = getConditionStrings(trigger);
+
+            return (
+              <Grid key={trigger.id}>
+                <Label>{trigger.label}</Label>
+                <Condition>
+                  <MainCondition>{mainCondition}</MainCondition>
+                  {secondaryCondition !== null && (
+                    <SecondaryCondition>{secondaryCondition}</SecondaryCondition>
+                  )}
+                </Condition>
+                <Actions>
+                  N/A
+                  <ButtonBar>
+                    <Button
+                      type="button"
+                      priority="default"
+                      icon="icon-edit"
+                      size="small"
+                      onClick={() => this.handleEdit(trigger)}
+                    >
+                      {t('Edit')}
+                    </Button>
+                    <Confirm
+                      onConfirm={() => this.handleDelete(trigger)}
+                      message={t('Are you sure you want to delete this trigger?')}
+                      priority="danger"
+                    >
+                      <Button
+                        priority="danger"
+                        size="small"
+                        aria-label={t('Delete Trigger')}
+                        icon="icon-trash"
+                      />
+                    </Confirm>
+                  </ButtonBar>
+                </Actions>
+              </Grid>
+            );
+          })}
         </PanelBody>
       </Panel>
     );
@@ -53,18 +110,15 @@ const PanelHeaderGrid = styled(PanelHeader)`
   ${gridCss};
 `;
 
-const Grid = styled('div')`
+const Grid = styled(PanelItem)`
   ${gridCss};
-  padding: ${space(2)};
 `;
 
-const Cell = styled('div')``;
-
-const Label = styled(Cell)`
+const Label = styled('div')`
   font-size: 1.2em;
 `;
 
-const Condition = styled(Cell)``;
+const Condition = styled('div')``;
 
 const MainCondition = styled('div')``;
 const SecondaryCondition = styled('div')`
@@ -72,8 +126,14 @@ const SecondaryCondition = styled('div')`
   color: ${p => p.theme.gray2};
 `;
 
-const Actions = styled(Cell)`
+const Actions = styled('div')`
   display: flex;
   justify-content: space-between;
   align-items: center;
 `;
+
+const ButtonBar = styled('div')`
+  display: grid;
+  grid-gap: ${space(1)};
+  grid-auto-flow: column;
+`;

+ 26 - 6
src/sentry/static/sentry/app/views/settings/incidentRules/triggers/modal.tsx

@@ -1,10 +1,10 @@
 import React from 'react';
 import styled from 'react-emotion';
 
-import {IncidentRule} from 'app/views/settings/incidentRules/types';
+import {IncidentRule, Trigger} from 'app/views/settings/incidentRules/types';
 import {Organization, Project} from 'app/types';
-import {addSuccessMessage} from 'app/actionCreators/indicator';
-import {t} from 'app/locale';
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import {t, tct} from 'app/locale';
 import TriggerForm from 'app/views/settings/incidentRules/triggers/form';
 import space from 'app/styles/space';
 
@@ -12,15 +12,34 @@ type Props = {
   organization: Organization;
   projects: Project[];
   rule: IncidentRule;
+  closeModal: Function;
+  trigger?: Trigger;
+  onSubmitSuccess: Function;
 };
 
 class TriggersModal extends React.Component<Props> {
-  handleSubmitSuccess = () => {
-    addSuccessMessage(t('Successfully saved Incident Rule'));
+  handleSubmitSuccess = (newTrigger: Trigger) => {
+    const {onSubmitSuccess, closeModal, trigger} = this.props;
+
+    if (trigger) {
+      addSuccessMessage(
+        tct('Successfully updated trigger: [label]', {label: newTrigger.label})
+      );
+    } else {
+      addSuccessMessage(
+        tct('Successfully saved trigger: [label]', {label: newTrigger.label})
+      );
+    }
+    onSubmitSuccess(newTrigger);
+    closeModal();
+  };
+
+  handleSubmitError = () => {
+    addErrorMessage(t('There was a problem saving trigger'));
   };
 
   render() {
-    const {organization, projects, rule} = this.props;
+    const {organization, projects, rule, trigger} = this.props;
     return (
       <div>
         <TinyHeader>{t('Trigger for')}</TinyHeader>
@@ -31,6 +50,7 @@ class TriggersModal extends React.Component<Props> {
           orgId={organization.slug}
           onSubmitSuccess={this.handleSubmitSuccess}
           rule={rule}
+          trigger={trigger}
         />
       </div>
     );

+ 17 - 0
src/sentry/static/sentry/app/views/settings/incidentRules/types.tsx

@@ -13,6 +13,22 @@ export enum AlertRuleAggregations {
   UNIQUE_USERS,
 }
 
+export type UnsavedTrigger = {
+  alertRuleId: string;
+  label: string;
+  thresholdType: AlertRuleThresholdType;
+  alertThreshold: number;
+  resolveThreshold: number;
+  timeWindow: number;
+};
+
+export type SavedTrigger = UnsavedTrigger & {
+  id: string;
+  dateAdded: string;
+};
+
+export type Trigger = Partial<SavedTrigger> & UnsavedTrigger;
+
 export type IncidentRule = {
   aggregations: number[];
   aggregation?: number;
@@ -30,6 +46,7 @@ export type IncidentRule = {
   thresholdPeriod: number;
   thresholdType: number;
   timeWindow: number;
+  triggers: Trigger[];
 };
 
 export enum TimeWindow {

+ 3 - 0
tests/js/fixtures/incidentRule.js

@@ -1,3 +1,5 @@
+import {IncidentTrigger} from './incidentTrigger';
+
 export function IncidentRule(params) {
   return {
     status: 0,
@@ -15,6 +17,7 @@ export function IncidentRule(params) {
     projectId: '1',
     resolution: 1,
     dateModified: '2019-07-31T23:02:02.731Z',
+    triggers: [IncidentTrigger()],
     ...params,
   };
 }

+ 12 - 0
tests/js/fixtures/incidentTrigger.js

@@ -0,0 +1,12 @@
+export function IncidentTrigger(params) {
+  return {
+    alertRuleId: '4',
+    alertThreshold: 70,
+    dateAdded: '2019-09-24T18:07:47.714Z',
+    id: '1',
+    label: 'Trigger',
+    resolveThreshold: 36,
+    thresholdType: 0,
+    ...params,
+  };
+}

+ 181 - 10
tests/js/spec/views/settings/incidentRules/details.spec.jsx

@@ -1,28 +1,199 @@
-import {mount} from 'enzyme';
 import React from 'react';
+import {mount} from 'enzyme';
 
 import {initializeOrg} from 'app-test/helpers/initializeOrg';
 import IncidentRulesDetails from 'app/views/settings/incidentRules/details';
+import GlobalModal from 'app/components/globalModal';
 
 describe('Incident Rules Details', function() {
-  it('renders', function() {
+  MockApiClient.addMockResponse({
+    url: '/organizations/org-slug/tags/',
+    body: [],
+  });
+
+  it('renders and adds and edits trigger', async function() {
     const {organization, routerContext} = initializeOrg();
     const rule = TestStubs.IncidentRule();
     const req = MockApiClient.addMockResponse({
       url: `/organizations/${organization.slug}/alert-rules/${rule.id}/`,
       body: rule,
     });
-    mount(
-      <IncidentRulesDetails
-        params={{
-          orgId: organization.slug,
-          incidentRuleId: rule.id,
-        }}
-        organization={organization}
-      />,
+    const createTrigger = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/alert-rules/${rule.id}/triggers`,
+      method: 'POST',
+      body: (_, options) =>
+        TestStubs.IncidentTrigger({
+          ...options.data,
+          id: '123',
+        }),
+    });
+    const updateTrigger = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/alert-rules/${rule.id}/triggers/123`,
+      method: 'PUT',
+      body: (_, options) =>
+        TestStubs.IncidentTrigger({
+          ...options.data,
+        }),
+    });
+
+    const wrapper = mount(
+      <React.Fragment>
+        <GlobalModal />
+        <IncidentRulesDetails
+          params={{
+            orgId: organization.slug,
+            incidentRuleId: rule.id,
+          }}
+          organization={organization}
+        />
+      </React.Fragment>,
       routerContext
     );
+    wrapper.update();
 
     expect(req).toHaveBeenCalled();
+    expect(wrapper.find('TriggersList Label').text()).toBe('Trigger');
+
+    // Create a new Trigger
+    wrapper.find('button[aria-label="New Trigger"]').simulate('click');
+    await tick(); // tick opening a modal
+    wrapper.update();
+
+    wrapper
+      .find('TriggersModal input[name="label"]')
+      .simulate('change', {target: {value: 'New Trigger'}});
+    wrapper
+      .find('TriggersModal input[name="alertThreshold"]')
+      .simulate('input', {target: {value: 13}});
+    wrapper
+      .find('TriggersModal input[name="resolveThreshold"]')
+      .simulate('input', {target: {value: 12}});
+
+    // Save Trigger
+    wrapper.find('TriggersModal button[aria-label="Create Trigger"]').simulate('submit');
+    expect(createTrigger).toHaveBeenLastCalledWith(
+      expect.anything(),
+      expect.objectContaining({
+        data: {
+          label: 'New Trigger',
+          alertThreshold: 13,
+          resolveThreshold: 12,
+          thresholdType: 0,
+        },
+      })
+    );
+
+    // New Trigger should be in list
+    await tick();
+    wrapper.update();
+    expect(
+      wrapper
+        .find('TriggersList Label')
+        .last()
+        .text()
+    ).toBe('New Trigger');
+    expect(wrapper.find('TriggersModal')).toHaveLength(1);
+
+    // Edit new trigger
+    wrapper
+      .find('button[aria-label="Edit"]')
+      .last()
+      .simulate('click');
+    await tick(); // tick opening a modal
+    wrapper.update();
+
+    // Has correct values
+
+    expect(wrapper.find('TriggersModal input[name="label"]').prop('value')).toBe(
+      'New Trigger'
+    );
+    expect(wrapper.find('TriggersModal input[name="alertThreshold"]').prop('value')).toBe(
+      13
+    );
+    expect(
+      wrapper.find('TriggersModal input[name="resolveThreshold"]').prop('value')
+    ).toBe(12);
+
+    wrapper
+      .find('TriggersModal input[name="label"]')
+      .simulate('change', {target: {value: 'New Trigger!!'}});
+
+    // Save Trigger
+    wrapper.find('TriggersModal button[aria-label="Update Trigger"]').simulate('submit');
+    expect(updateTrigger).toHaveBeenLastCalledWith(
+      expect.anything(),
+      expect.objectContaining({
+        data: expect.objectContaining({
+          alertRuleId: '4',
+          alertThreshold: 13,
+          id: '123',
+          label: 'New Trigger!!',
+          resolveThreshold: 12,
+          thresholdType: 0,
+        }),
+      })
+    );
+    // New Trigger should be in list
+    await tick();
+    wrapper.update();
+
+    expect(
+      wrapper
+        .find('TriggersList Label')
+        .last()
+        .text()
+    ).toBe('New Trigger!!');
+    expect(wrapper.find('TriggersModal')).toHaveLength(1);
+
+    // Attempt and fail to delete trigger
+    let deleteTrigger = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/alert-rules/${rule.id}/triggers/123`,
+      method: 'DELETE',
+      statusCode: 400,
+    });
+
+    wrapper
+      .find('TriggersList button[aria-label="Delete Trigger"]')
+      .last()
+      .simulate('click');
+
+    wrapper.find('Confirm button[aria-label="Confirm"]').simulate('click');
+
+    await tick();
+    wrapper.update();
+
+    expect(deleteTrigger).toHaveBeenCalled();
+
+    expect(
+      wrapper
+        .find('TriggersList Label')
+        .last()
+        .text()
+    ).toBe('New Trigger!!');
+
+    // Actually delete trigger
+    deleteTrigger = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/alert-rules/${rule.id}/triggers/123`,
+      method: 'DELETE',
+    });
+
+    wrapper
+      .find('TriggersList button[aria-label="Delete Trigger"]')
+      .last()
+      .simulate('click');
+
+    wrapper.find('Confirm button[aria-label="Confirm"]').simulate('click');
+
+    await tick();
+    wrapper.update();
+
+    expect(deleteTrigger).toHaveBeenCalled();
+
+    expect(
+      wrapper
+        .find('TriggersList Label')
+        .last()
+        .text()
+    ).toBe('Trigger');
   });
 });

+ 9 - 2
tests/js/spec/views/settings/incidentRules/ruleForm.spec.jsx

@@ -17,10 +17,18 @@ describe('Incident Rules Form', function() {
       />,
       routerContext
     );
+
+  beforeEach(function() {
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/tags/',
+      body: [],
+    });
+  });
+
   describe('Creating a new rule', function() {
     let createRule;
     beforeEach(function() {
-      MockApiClient.clearMockResponses();
       createRule = MockApiClient.addMockResponse({
         url: '/organizations/org-slug/alert-rules/',
         method: 'POST',
@@ -91,7 +99,6 @@ describe('Incident Rules Form', function() {
     const rule = TestStubs.IncidentRule();
 
     beforeEach(function() {
-      MockApiClient.clearMockResponses();
       editRule = MockApiClient.addMockResponse({
         url: `/organizations/org-slug/alert-rules/${rule.id}/`,
         method: 'PUT',