index.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353
  1. import {ChangeEvent, Fragment, ReactNode} from 'react';
  2. import {browserHistory, RouteComponentProps} from 'react-router';
  3. import {components} from 'react-select';
  4. import styled from '@emotion/styled';
  5. import classNames from 'classnames';
  6. import {Location} from 'history';
  7. import cloneDeep from 'lodash/cloneDeep';
  8. import omit from 'lodash/omit';
  9. import set from 'lodash/set';
  10. import {
  11. addErrorMessage,
  12. addLoadingMessage,
  13. addSuccessMessage,
  14. } from 'sentry/actionCreators/indicator';
  15. import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
  16. import Access from 'sentry/components/acl/access';
  17. import Alert from 'sentry/components/alert';
  18. import Button from 'sentry/components/button';
  19. import Confirm from 'sentry/components/confirm';
  20. import Input from 'sentry/components/forms/controls/input';
  21. import Field from 'sentry/components/forms/field';
  22. import FieldHelp from 'sentry/components/forms/field/fieldHelp';
  23. import Form from 'sentry/components/forms/form';
  24. import FormField from 'sentry/components/forms/formField';
  25. import SelectControl from 'sentry/components/forms/selectControl';
  26. import SelectField from 'sentry/components/forms/selectField';
  27. import TeamSelector from 'sentry/components/forms/teamSelector';
  28. import IdBadge from 'sentry/components/idBadge';
  29. import * as Layout from 'sentry/components/layouts/thirds';
  30. import List from 'sentry/components/list';
  31. import ListItem from 'sentry/components/list/listItem';
  32. import LoadingMask from 'sentry/components/loadingMask';
  33. import {Panel, PanelBody} from 'sentry/components/panels';
  34. import {ALL_ENVIRONMENTS_KEY} from 'sentry/constants';
  35. import {IconChevron} from 'sentry/icons';
  36. import {t, tct} from 'sentry/locale';
  37. import ConfigStore from 'sentry/stores/configStore';
  38. import HookStore from 'sentry/stores/hookStore';
  39. import space from 'sentry/styles/space';
  40. import {Environment, OnboardingTaskKey, Organization, Project, Team} from 'sentry/types';
  41. import {
  42. IssueAlertRule,
  43. IssueAlertRuleAction,
  44. IssueAlertRuleActionTemplate,
  45. IssueAlertRuleConditionTemplate,
  46. UnsavedIssueAlertRule,
  47. } from 'sentry/types/alerts';
  48. import {metric} from 'sentry/utils/analytics';
  49. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  50. import {getDisplayName} from 'sentry/utils/environment';
  51. import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
  52. import recreateRoute from 'sentry/utils/recreateRoute';
  53. import routeTitleGen from 'sentry/utils/routeTitle';
  54. import withExperiment from 'sentry/utils/withExperiment';
  55. import withOrganization from 'sentry/utils/withOrganization';
  56. import withProjects from 'sentry/utils/withProjects';
  57. import {
  58. CHANGE_ALERT_CONDITION_IDS,
  59. CHANGE_ALERT_PLACEHOLDERS_LABELS,
  60. } from 'sentry/views/alerts/utils/constants';
  61. import AsyncView from 'sentry/views/asyncView';
  62. import RuleNodeList from './ruleNodeList';
  63. import SetupAlertIntegrationButton from './setupAlertIntegrationButton';
  64. const FREQUENCY_OPTIONS = [
  65. {value: '5', label: t('5 minutes')},
  66. {value: '10', label: t('10 minutes')},
  67. {value: '30', label: t('30 minutes')},
  68. {value: '60', label: t('60 minutes')},
  69. {value: '180', label: t('3 hours')},
  70. {value: '720', label: t('12 hours')},
  71. {value: '1440', label: t('24 hours')},
  72. {value: '10080', label: t('1 week')},
  73. {value: '43200', label: t('30 days')},
  74. ];
  75. const ACTION_MATCH_OPTIONS = [
  76. {value: 'all', label: t('all')},
  77. {value: 'any', label: t('any')},
  78. {value: 'none', label: t('none')},
  79. ];
  80. const ACTION_MATCH_OPTIONS_MIGRATED = [
  81. {value: 'all', label: t('all')},
  82. {value: 'any', label: t('any')},
  83. ];
  84. const defaultRule: UnsavedIssueAlertRule = {
  85. actionMatch: 'all',
  86. filterMatch: 'all',
  87. actions: [],
  88. conditions: [],
  89. filters: [],
  90. name: '',
  91. frequency: 30,
  92. environment: ALL_ENVIRONMENTS_KEY,
  93. };
  94. const POLLING_MAX_TIME_LIMIT = 3 * 60000;
  95. type ConditionOrActionProperty = 'conditions' | 'actions' | 'filters';
  96. type RuleTaskResponse = {
  97. status: 'pending' | 'failed' | 'success';
  98. error?: string;
  99. rule?: IssueAlertRule;
  100. };
  101. type RouteParams = {orgId: string; projectId?: string; ruleId?: string};
  102. type Props = {
  103. experimentAssignment: 0 | 1;
  104. location: Location;
  105. logExperiment: () => void;
  106. organization: Organization;
  107. project: Project;
  108. projects: Project[];
  109. userTeamIds: string[];
  110. loadingProjects?: boolean;
  111. onChangeTitle?: (data: string) => void;
  112. } & RouteComponentProps<RouteParams, {}>;
  113. type State = AsyncView['state'] & {
  114. configs: {
  115. actions: IssueAlertRuleActionTemplate[];
  116. conditions: IssueAlertRuleConditionTemplate[];
  117. filters: IssueAlertRuleConditionTemplate[];
  118. } | null;
  119. detailedError: null | {
  120. [key: string]: string[];
  121. };
  122. environments: Environment[] | null;
  123. project: Project;
  124. uuid: null | string;
  125. duplicateTargetRule?: UnsavedIssueAlertRule | IssueAlertRule | null;
  126. rule?: UnsavedIssueAlertRule | IssueAlertRule | null;
  127. };
  128. function isSavedAlertRule(rule: State['rule']): rule is IssueAlertRule {
  129. return rule?.hasOwnProperty('id') ?? false;
  130. }
  131. class IssueRuleEditor extends AsyncView<Props, State> {
  132. pollingTimeout: number | undefined = undefined;
  133. get isDuplicateRule(): boolean {
  134. const {location, organization} = this.props;
  135. const createFromDuplicate = location?.query.createFromDuplicate === 'true';
  136. const hasDuplicateAlertRules = organization.features.includes('duplicate-alert-rule');
  137. return (
  138. hasDuplicateAlertRules && createFromDuplicate && location?.query.duplicateRuleId
  139. );
  140. }
  141. get hasAlertWizardV3(): boolean {
  142. return this.props.organization.features.includes('alert-wizard-v3');
  143. }
  144. componentWillUnmount() {
  145. window.clearTimeout(this.pollingTimeout);
  146. }
  147. componentDidMount() {
  148. const {params, organization, experimentAssignment, logExperiment} = this.props;
  149. // only new rules
  150. if (params.ruleId) {
  151. return;
  152. }
  153. // check if there is a callback registered
  154. const callback = HookStore.get('callback:default-action-alert-rule')[0];
  155. if (!callback) {
  156. return;
  157. }
  158. // let hook decide when we want to select a default alert rule
  159. callback((showDefaultAction: boolean) => {
  160. if (showDefaultAction) {
  161. const user = ConfigStore.get('user');
  162. const {rule} = this.state;
  163. // always log the experiment if we meet the basic requirements decided by the hook
  164. logExperiment();
  165. if (experimentAssignment) {
  166. // this will add a default alert rule action
  167. // to send notifications in
  168. this.setState({
  169. rule: {
  170. ...rule,
  171. actions: [
  172. {
  173. id: 'sentry.mail.actions.NotifyEmailAction',
  174. targetIdentifier: user.id,
  175. targetType: 'Member',
  176. } as any, // Need to fix IssueAlertRuleAction typing
  177. ],
  178. } as UnsavedIssueAlertRule,
  179. });
  180. }
  181. }
  182. }, organization);
  183. }
  184. componentDidUpdate(_prevProps: Props, prevState: State) {
  185. if (prevState.project.id === this.state.project.id) {
  186. return;
  187. }
  188. this.fetchEnvironments();
  189. }
  190. getTitle() {
  191. const {organization} = this.props;
  192. const {rule, project} = this.state;
  193. const ruleName = rule?.name;
  194. return routeTitleGen(
  195. ruleName ? t('Alert %s', ruleName) : '',
  196. organization.slug,
  197. false,
  198. project?.slug
  199. );
  200. }
  201. getDefaultState() {
  202. const {userTeamIds, project} = this.props;
  203. const defaultState = {
  204. ...super.getDefaultState(),
  205. configs: null,
  206. detailedError: null,
  207. rule: {...defaultRule},
  208. environments: [],
  209. uuid: null,
  210. project,
  211. };
  212. const projectTeamIds = new Set(project.teams.map(({id}) => id));
  213. const userTeamId = userTeamIds.find(id => projectTeamIds.has(id)) ?? null;
  214. defaultState.rule.owner = userTeamId && `team:${userTeamId}`;
  215. return defaultState;
  216. }
  217. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  218. const {
  219. organization,
  220. location: {query},
  221. params: {ruleId, orgId},
  222. } = this.props;
  223. // project in state isn't initialized when getEndpoints is first called
  224. const project = this.state?.project ?? this.props.project;
  225. const hasDuplicateAlertRules = organization.features.includes('duplicate-alert-rule');
  226. const endpoints = [
  227. ['environments', `/projects/${orgId}/${project.slug}/environments/`],
  228. ['configs', `/projects/${orgId}/${project.slug}/rules/configuration/`],
  229. ];
  230. if (ruleId) {
  231. endpoints.push(['rule', `/projects/${orgId}/${project.slug}/rules/${ruleId}/`]);
  232. }
  233. if (
  234. hasDuplicateAlertRules &&
  235. !ruleId &&
  236. query.createFromDuplicate &&
  237. query.duplicateRuleId
  238. ) {
  239. endpoints.push([
  240. 'duplicateTargetRule',
  241. `/projects/${orgId}/${project.slug}/rules/${query.duplicateRuleId}/`,
  242. ]);
  243. }
  244. return endpoints as [string, string][];
  245. }
  246. onRequestSuccess({stateKey, data}) {
  247. if (stateKey === 'rule' && data.name) {
  248. this.props.onChangeTitle?.(data.name);
  249. }
  250. if (stateKey === 'duplicateTargetRule') {
  251. this.setState({
  252. rule: {
  253. ...omit(data, ['id']),
  254. name: data.name + ' copy',
  255. } as UnsavedIssueAlertRule,
  256. });
  257. }
  258. }
  259. onLoadAllEndpointsSuccess() {
  260. const {rule} = this.state;
  261. if (rule) {
  262. ((rule as IssueAlertRule)?.errors || []).map(({detail}) =>
  263. addErrorMessage(detail, {append: true})
  264. );
  265. }
  266. }
  267. pollHandler = async (quitTime: number) => {
  268. if (Date.now() > quitTime) {
  269. addErrorMessage(t('Looking for that channel took too long :('));
  270. this.setState({loading: false});
  271. return;
  272. }
  273. const {organization} = this.props;
  274. const {uuid, project} = this.state;
  275. const origRule = this.state.rule;
  276. try {
  277. const response: RuleTaskResponse = await this.api.requestPromise(
  278. `/projects/${organization.slug}/${project.slug}/rule-task/${uuid}/`
  279. );
  280. const {status, rule, error} = response;
  281. if (status === 'pending') {
  282. window.clearTimeout(this.pollingTimeout);
  283. this.pollingTimeout = window.setTimeout(() => {
  284. this.pollHandler(quitTime);
  285. }, 1000);
  286. return;
  287. }
  288. if (status === 'failed') {
  289. this.setState({
  290. detailedError: {actions: [error ? error : t('An error occurred')]},
  291. loading: false,
  292. });
  293. this.handleRuleSaveFailure(t('An error occurred'));
  294. }
  295. if (rule) {
  296. const ruleId = isSavedAlertRule(origRule) ? `${origRule.id}/` : '';
  297. const isNew = !ruleId;
  298. this.handleRuleSuccess(isNew, rule);
  299. }
  300. } catch {
  301. this.handleRuleSaveFailure(t('An error occurred'));
  302. this.setState({loading: false});
  303. }
  304. };
  305. fetchEnvironments() {
  306. const {
  307. params: {orgId},
  308. } = this.props;
  309. const {project} = this.state;
  310. this.api
  311. .requestPromise(`/projects/${orgId}/${project.slug}/environments/`, {
  312. query: {
  313. visibility: 'visible',
  314. },
  315. })
  316. .then(response => this.setState({environments: response}))
  317. .catch(_err => addErrorMessage(t('Unable to fetch environments')));
  318. }
  319. fetchStatus() {
  320. // pollHandler calls itself until it gets either a success
  321. // or failed status but we don't want to poll forever so we pass
  322. // in a hard stop time of 3 minutes before we bail.
  323. const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
  324. window.clearTimeout(this.pollingTimeout);
  325. this.pollingTimeout = window.setTimeout(() => {
  326. this.pollHandler(quitTime);
  327. }, 1000);
  328. }
  329. handleRuleSuccess = (isNew: boolean, rule: IssueAlertRule) => {
  330. const {organization, router} = this.props;
  331. const {project} = this.state;
  332. this.setState({detailedError: null, loading: false, rule});
  333. // The onboarding task will be completed on the server side when the alert
  334. // is created
  335. updateOnboardingTask(null, organization, {
  336. task: OnboardingTaskKey.ALERT_RULE,
  337. status: 'complete',
  338. });
  339. metric.endTransaction({name: 'saveAlertRule'});
  340. router.push({
  341. pathname: `/organizations/${organization.slug}/alerts/rules/${project.slug}/${rule.id}/details/`,
  342. });
  343. addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
  344. };
  345. handleRuleSaveFailure(msg: ReactNode) {
  346. addErrorMessage(msg);
  347. metric.endTransaction({name: 'saveAlertRule'});
  348. }
  349. handleSubmit = async () => {
  350. const {project, rule} = this.state;
  351. const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
  352. const isNew = !ruleId;
  353. const {organization} = this.props;
  354. const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
  355. if (rule && rule.environment === ALL_ENVIRONMENTS_KEY) {
  356. delete rule.environment;
  357. }
  358. addLoadingMessage();
  359. try {
  360. const transaction = metric.startTransaction({name: 'saveAlertRule'});
  361. transaction.setTag('type', 'issue');
  362. transaction.setTag('operation', isNew ? 'create' : 'edit');
  363. if (rule) {
  364. for (const action of rule.actions) {
  365. // Grab the last part of something like 'sentry.mail.actions.NotifyEmailAction'
  366. const splitActionId = action.id.split('.');
  367. const actionName = splitActionId[splitActionId.length - 1];
  368. if (actionName === 'SlackNotifyServiceAction') {
  369. transaction.setTag(actionName, true);
  370. }
  371. }
  372. transaction.setData('actions', rule.actions);
  373. }
  374. const [data, , resp] = await this.api.requestPromise(endpoint, {
  375. includeAllArgs: true,
  376. method: isNew ? 'POST' : 'PUT',
  377. data: rule,
  378. query: {
  379. duplicateRule: this.isDuplicateRule ? 'true' : 'false',
  380. wizardV3: this.hasAlertWizardV3 ? 'true' : 'false',
  381. },
  382. });
  383. // if we get a 202 back it means that we have an async task
  384. // running to lookup and verify the channel id for Slack.
  385. if (resp?.status === 202) {
  386. this.setState({detailedError: null, loading: true, uuid: data.uuid});
  387. this.fetchStatus();
  388. addLoadingMessage(t('Looking through all your channels...'));
  389. } else {
  390. this.handleRuleSuccess(isNew, data);
  391. }
  392. } catch (err) {
  393. this.setState({
  394. detailedError: err.responseJSON || {__all__: 'Unknown error'},
  395. loading: false,
  396. });
  397. this.handleRuleSaveFailure(t('An error occurred'));
  398. }
  399. };
  400. handleDeleteRule = async () => {
  401. const {project, rule} = this.state;
  402. const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
  403. const isNew = !ruleId;
  404. const {organization} = this.props;
  405. if (isNew) {
  406. return;
  407. }
  408. const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
  409. addLoadingMessage(t('Deleting...'));
  410. try {
  411. await this.api.requestPromise(endpoint, {
  412. method: 'DELETE',
  413. });
  414. addSuccessMessage(t('Deleted alert rule'));
  415. browserHistory.replace(recreateRoute('', {...this.props, stepBack: -2}));
  416. } catch (err) {
  417. this.setState({
  418. detailedError: err.responseJSON || {__all__: 'Unknown error'},
  419. });
  420. addErrorMessage(t('There was a problem deleting the alert'));
  421. }
  422. };
  423. handleCancel = () => {
  424. const {organization, router} = this.props;
  425. router.push(`/organizations/${organization.slug}/alerts/rules/`);
  426. };
  427. hasError = (field: string) => {
  428. const {detailedError} = this.state;
  429. if (!detailedError) {
  430. return false;
  431. }
  432. return detailedError.hasOwnProperty(field);
  433. };
  434. handleEnvironmentChange = (val: string) => {
  435. // If 'All Environments' is selected the value should be null
  436. if (val === ALL_ENVIRONMENTS_KEY) {
  437. this.handleChange('environment', null);
  438. } else {
  439. this.handleChange('environment', val);
  440. }
  441. };
  442. handleChange = <T extends keyof IssueAlertRule>(prop: T, val: IssueAlertRule[T]) => {
  443. this.setState(prevState => {
  444. const clonedState = cloneDeep(prevState);
  445. set(clonedState, `rule[${prop}]`, val);
  446. return {...clonedState, detailedError: omit(prevState.detailedError, prop)};
  447. });
  448. };
  449. handlePropertyChange = <T extends keyof IssueAlertRuleAction>(
  450. type: ConditionOrActionProperty,
  451. idx: number,
  452. prop: T,
  453. val: IssueAlertRuleAction[T]
  454. ) => {
  455. this.setState(prevState => {
  456. const clonedState = cloneDeep(prevState);
  457. set(clonedState, `rule[${type}][${idx}][${prop}]`, val);
  458. return clonedState;
  459. });
  460. };
  461. getInitialValue = (type: ConditionOrActionProperty, id: string) => {
  462. const configuration = this.state.configs?.[type]?.find(c => c.id === id);
  463. const hasChangeAlerts =
  464. configuration?.id &&
  465. this.props.organization.features.includes('change-alerts') &&
  466. CHANGE_ALERT_CONDITION_IDS.includes(configuration.id);
  467. return configuration?.formFields
  468. ? Object.fromEntries(
  469. Object.entries(configuration.formFields)
  470. // TODO(ts): Doesn't work if I cast formField as IssueAlertRuleFormField
  471. .map(([key, formField]: [string, any]) => [
  472. key,
  473. hasChangeAlerts && key === 'interval'
  474. ? '1h'
  475. : formField?.initial ?? formField?.choices?.[0]?.[0],
  476. ])
  477. .filter(([, initial]) => !!initial)
  478. )
  479. : {};
  480. };
  481. handleResetRow = <T extends keyof IssueAlertRuleAction>(
  482. type: ConditionOrActionProperty,
  483. idx: number,
  484. prop: T,
  485. val: IssueAlertRuleAction[T]
  486. ) => {
  487. this.setState(prevState => {
  488. const clonedState = cloneDeep(prevState);
  489. // Set initial configuration, but also set
  490. const id = (clonedState.rule as IssueAlertRule)[type][idx].id;
  491. const newRule = {
  492. ...this.getInitialValue(type, id),
  493. id,
  494. [prop]: val,
  495. };
  496. set(clonedState, `rule[${type}][${idx}]`, newRule);
  497. return clonedState;
  498. });
  499. };
  500. handleAddRow = (type: ConditionOrActionProperty, id: string) => {
  501. this.setState(prevState => {
  502. const clonedState = cloneDeep(prevState);
  503. // Set initial configuration
  504. const newRule = {
  505. ...this.getInitialValue(type, id),
  506. id,
  507. };
  508. const newTypeList = prevState.rule ? prevState.rule[type] : [];
  509. set(clonedState, `rule[${type}]`, [...newTypeList, newRule]);
  510. return clonedState;
  511. });
  512. const {organization} = this.props;
  513. const {project} = this.state;
  514. trackAdvancedAnalyticsEvent('edit_alert_rule.add_row', {
  515. organization,
  516. project_id: project.id,
  517. type,
  518. name: id,
  519. });
  520. };
  521. handleDeleteRow = (type: ConditionOrActionProperty, idx: number) => {
  522. this.setState(prevState => {
  523. const clonedState = cloneDeep(prevState);
  524. const newTypeList = prevState.rule ? prevState.rule[type] : [];
  525. if (prevState.rule) {
  526. newTypeList.splice(idx, 1);
  527. }
  528. set(clonedState, `rule[${type}]`, newTypeList);
  529. return clonedState;
  530. });
  531. };
  532. handleAddCondition = (id: string) => this.handleAddRow('conditions', id);
  533. handleAddAction = (id: string) => this.handleAddRow('actions', id);
  534. handleAddFilter = (id: string) => this.handleAddRow('filters', id);
  535. handleDeleteCondition = (ruleIndex: number) =>
  536. this.handleDeleteRow('conditions', ruleIndex);
  537. handleDeleteAction = (ruleIndex: number) => this.handleDeleteRow('actions', ruleIndex);
  538. handleDeleteFilter = (ruleIndex: number) => this.handleDeleteRow('filters', ruleIndex);
  539. handleChangeConditionProperty = (ruleIndex: number, prop: string, val: string) =>
  540. this.handlePropertyChange('conditions', ruleIndex, prop, val);
  541. handleChangeActionProperty = (ruleIndex: number, prop: string, val: string) =>
  542. this.handlePropertyChange('actions', ruleIndex, prop, val);
  543. handleChangeFilterProperty = (ruleIndex: number, prop: string, val: string) =>
  544. this.handlePropertyChange('filters', ruleIndex, prop, val);
  545. handleResetCondition = (ruleIndex: number, prop: string, value: string) =>
  546. this.handleResetRow('conditions', ruleIndex, prop, value);
  547. handleResetAction = (ruleIndex: number, prop: string, value: string) =>
  548. this.handleResetRow('actions', ruleIndex, prop, value);
  549. handleResetFilter = (ruleIndex: number, prop: string, value: string) =>
  550. this.handleResetRow('filters', ruleIndex, prop, value);
  551. handleValidateRuleName = () => {
  552. const isRuleNameEmpty = !this.state.rule?.name.trim();
  553. if (!isRuleNameEmpty) {
  554. return;
  555. }
  556. this.setState(prevState => ({
  557. detailedError: {
  558. ...prevState.detailedError,
  559. name: [t('Field Required')],
  560. },
  561. }));
  562. };
  563. getConditions() {
  564. const {organization} = this.props;
  565. if (!organization.features.includes('change-alerts')) {
  566. return this.state.configs?.conditions ?? null;
  567. }
  568. return (
  569. this.state.configs?.conditions?.map(condition =>
  570. CHANGE_ALERT_CONDITION_IDS.includes(condition.id)
  571. ? ({
  572. ...condition,
  573. label: CHANGE_ALERT_PLACEHOLDERS_LABELS[condition.id],
  574. } as IssueAlertRuleConditionTemplate)
  575. : condition
  576. ) ?? null
  577. );
  578. }
  579. getTeamId = () => {
  580. const {rule} = this.state;
  581. const owner = rule?.owner;
  582. // ownership follows the format team:<id>, just grab the id
  583. return owner && owner.split(':')[1];
  584. };
  585. handleOwnerChange = ({value}: {value: string}) => {
  586. const ownerValue = value && `team:${value}`;
  587. this.handleChange('owner', ownerValue);
  588. };
  589. renderLoading() {
  590. return this.renderBody();
  591. }
  592. renderError() {
  593. return (
  594. <Alert type="error" showIcon>
  595. {t(
  596. 'Unable to access this alert rule -- check to make sure you have the correct permissions'
  597. )}
  598. </Alert>
  599. );
  600. }
  601. renderRuleName(disabled: boolean) {
  602. const {rule, detailedError} = this.state;
  603. const {name} = rule || {};
  604. return (
  605. <StyledField
  606. hasAlertWizardV3={this.hasAlertWizardV3}
  607. label={this.hasAlertWizardV3 ? null : t('Alert name')}
  608. help={this.hasAlertWizardV3 ? null : t('Add a name for this alert')}
  609. error={detailedError?.name?.[0]}
  610. disabled={disabled}
  611. required
  612. stacked
  613. flexibleControlStateSize={this.hasAlertWizardV3 ? true : undefined}
  614. >
  615. <Input
  616. type="text"
  617. name="name"
  618. value={name}
  619. data-test-id="alert-name"
  620. placeholder={this.hasAlertWizardV3 ? t('Enter Alert Name') : t('My Rule Name')}
  621. onChange={(event: ChangeEvent<HTMLInputElement>) =>
  622. this.handleChange('name', event.target.value)
  623. }
  624. onBlur={this.handleValidateRuleName}
  625. disabled={disabled}
  626. />
  627. </StyledField>
  628. );
  629. }
  630. renderTeamSelect(disabled: boolean) {
  631. const {rule, project} = this.state;
  632. const ownerId = rule?.owner?.split(':')[1];
  633. return (
  634. <StyledField
  635. hasAlertWizardV3={this.hasAlertWizardV3}
  636. extraMargin
  637. label={this.hasAlertWizardV3 ? null : t('Team')}
  638. help={this.hasAlertWizardV3 ? null : t('The team that can edit this alert.')}
  639. disabled={disabled}
  640. flexibleControlStateSize={this.hasAlertWizardV3 ? true : undefined}
  641. >
  642. <TeamSelector
  643. value={this.getTeamId()}
  644. project={project}
  645. onChange={this.handleOwnerChange}
  646. teamFilter={(team: Team) => team.isMember || team.id === ownerId}
  647. useId
  648. includeUnassigned
  649. disabled={disabled}
  650. />
  651. </StyledField>
  652. );
  653. }
  654. renderIdBadge(project: Project) {
  655. return (
  656. <IdBadge
  657. project={project}
  658. avatarProps={{consistentWidth: true}}
  659. avatarSize={18}
  660. disableLink
  661. hideName
  662. />
  663. );
  664. }
  665. renderProjectSelect(disabled: boolean) {
  666. const {project: _selectedProject, projects, organization} = this.props;
  667. const hasOpenMembership = organization.features.includes('open-membership');
  668. const myProjects = projects.filter(project => project.hasAccess && project.isMember);
  669. const allProjects = projects.filter(
  670. project => project.hasAccess && !project.isMember
  671. );
  672. const myProjectOptions = myProjects.map(myProject => ({
  673. value: myProject.id,
  674. label: myProject.slug,
  675. leadingItems: this.renderIdBadge(myProject),
  676. }));
  677. const openMembershipProjects = [
  678. {
  679. label: t('My Projects'),
  680. options: myProjectOptions,
  681. },
  682. {
  683. label: t('All Projects'),
  684. options: allProjects.map(allProject => ({
  685. value: allProject.id,
  686. label: allProject.slug,
  687. leadingItems: this.renderIdBadge(allProject),
  688. })),
  689. },
  690. ];
  691. const projectOptions =
  692. hasOpenMembership || isActiveSuperuser()
  693. ? openMembershipProjects
  694. : myProjectOptions;
  695. return (
  696. <FormField
  697. name="projectId"
  698. inline={false}
  699. style={{padding: 0}}
  700. flexibleControlStateSize
  701. >
  702. {({onChange, onBlur, model}) => {
  703. const selectedProject =
  704. projects.find(({id}) => id === model.getValue('projectId')) ||
  705. _selectedProject;
  706. return (
  707. <SelectControl
  708. disabled={disabled}
  709. value={selectedProject.id}
  710. styles={{
  711. container: (provided: {[x: string]: string | number | boolean}) => ({
  712. ...provided,
  713. marginBottom: `${space(1)}`,
  714. }),
  715. }}
  716. options={projectOptions}
  717. onChange={({value}: {value: Project['id']}) => {
  718. // if the current owner/team isn't part of project selected, update to the first available team
  719. const nextSelectedProject =
  720. projects.find(({id}) => id === value) ?? selectedProject;
  721. const ownerId: String | undefined = model
  722. .getValue('owner')
  723. ?.split(':')[1];
  724. if (
  725. ownerId &&
  726. nextSelectedProject.teams.find(({id}) => id === ownerId) ===
  727. undefined &&
  728. nextSelectedProject.teams.length
  729. ) {
  730. this.handleOwnerChange({value: nextSelectedProject.teams[0].id});
  731. }
  732. this.setState({project: nextSelectedProject});
  733. onChange(value, {});
  734. onBlur(value, {});
  735. }}
  736. components={{
  737. SingleValue: containerProps => (
  738. <components.ValueContainer {...containerProps}>
  739. <IdBadge
  740. project={selectedProject}
  741. avatarProps={{consistentWidth: true}}
  742. avatarSize={18}
  743. disableLink
  744. />
  745. </components.ValueContainer>
  746. ),
  747. }}
  748. />
  749. );
  750. }}
  751. </FormField>
  752. );
  753. }
  754. renderActionInterval(disabled: boolean) {
  755. const {rule} = this.state;
  756. const {frequency} = rule || {};
  757. return (
  758. <StyledSelectField
  759. hasAlertWizardV3={this.hasAlertWizardV3}
  760. label={this.hasAlertWizardV3 ? null : t('Action Interval')}
  761. help={
  762. this.hasAlertWizardV3
  763. ? null
  764. : t('Perform these actions once this often for an issue')
  765. }
  766. clearable={false}
  767. name="frequency"
  768. className={this.hasError('frequency') ? ' error' : ''}
  769. value={frequency}
  770. required
  771. options={FREQUENCY_OPTIONS}
  772. onChange={val => this.handleChange('frequency', val)}
  773. disabled={disabled}
  774. flexibleControlStateSize={this.hasAlertWizardV3 ? true : undefined}
  775. />
  776. );
  777. }
  778. renderBody() {
  779. const {organization} = this.props;
  780. const {environments, project, rule, detailedError, loading} = this.state;
  781. const {actions, filters, conditions, frequency} = rule || {};
  782. const environmentOptions = [
  783. {
  784. value: ALL_ENVIRONMENTS_KEY,
  785. label: t('All Environments'),
  786. },
  787. ...(environments?.map(env => ({value: env.name, label: getDisplayName(env)})) ??
  788. []),
  789. ];
  790. const environment =
  791. !rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
  792. // Note `key` on `<Form>` below is so that on initial load, we show
  793. // the form with a loading mask on top of it, but force a re-render by using
  794. // a different key when we have fetched the rule so that form inputs are filled in
  795. return (
  796. <Access access={['alerts:write']}>
  797. {({hasAccess}) => {
  798. // check if superuser or if user is on the alert's team
  799. const disabled = loading || !(isActiveSuperuser() || hasAccess);
  800. return (
  801. <Main fullWidth>
  802. <StyledForm
  803. key={isSavedAlertRule(rule) ? rule.id : undefined}
  804. onCancel={this.handleCancel}
  805. onSubmit={this.handleSubmit}
  806. initialData={{
  807. ...rule,
  808. environment,
  809. frequency: `${frequency}`,
  810. projectId: project.id,
  811. }}
  812. submitDisabled={disabled}
  813. submitLabel={t('Save Rule')}
  814. extraButton={
  815. isSavedAlertRule(rule) ? (
  816. <Confirm
  817. disabled={disabled}
  818. priority="danger"
  819. confirmText={t('Delete Rule')}
  820. onConfirm={this.handleDeleteRule}
  821. header={t('Delete Rule')}
  822. message={t('Are you sure you want to delete this rule?')}
  823. >
  824. <Button priority="danger" type="button">
  825. {t('Delete Rule')}
  826. </Button>
  827. </Confirm>
  828. ) : null
  829. }
  830. >
  831. <List symbol="colored-numeric">
  832. {loading && <SemiTransparentLoadingMask data-test-id="loading-mask" />}
  833. <StyledListItem>{t('Add alert settings')}</StyledListItem>
  834. {this.hasAlertWizardV3 ? (
  835. <SettingsContainer>
  836. <StyledSelectField
  837. hasAlertWizardV3={this.hasAlertWizardV3}
  838. className={classNames({
  839. error: this.hasError('environment'),
  840. })}
  841. placeholder={t('Select an Environment')}
  842. clearable={false}
  843. name="environment"
  844. options={environmentOptions}
  845. onChange={val => this.handleEnvironmentChange(val)}
  846. disabled={disabled}
  847. flexibleControlStateSize
  848. />
  849. {this.renderProjectSelect(disabled)}
  850. </SettingsContainer>
  851. ) : (
  852. <Panel>
  853. <PanelBody>
  854. <SelectField
  855. className={classNames({
  856. error: this.hasError('environment'),
  857. })}
  858. label={t('Environment')}
  859. help={t(
  860. 'Choose an environment for these conditions to apply to'
  861. )}
  862. placeholder={t('Select an Environment')}
  863. clearable={false}
  864. name="environment"
  865. options={environmentOptions}
  866. onChange={val => this.handleEnvironmentChange(val)}
  867. disabled={disabled}
  868. />
  869. {this.renderTeamSelect(disabled)}
  870. {this.renderRuleName(disabled)}
  871. </PanelBody>
  872. </Panel>
  873. )}
  874. <SetConditionsListItem>
  875. {t('Set conditions')}
  876. <SetupAlertIntegrationButton
  877. projectSlug={project.slug}
  878. organization={organization}
  879. />
  880. </SetConditionsListItem>
  881. <ConditionsPanel>
  882. <PanelBody>
  883. <Step>
  884. <StepConnector />
  885. <StepContainer>
  886. <ChevronContainer>
  887. <IconChevron
  888. color="gray200"
  889. isCircled
  890. direction="right"
  891. size="sm"
  892. />
  893. </ChevronContainer>
  894. <StepContent>
  895. <StepLead>
  896. {tct(
  897. '[when:When] an event is captured by Sentry and [selector] of the following happens',
  898. {
  899. when: <Badge />,
  900. selector: (
  901. <EmbeddedWrapper>
  902. <EmbeddedSelectField
  903. className={classNames({
  904. error: this.hasError('actionMatch'),
  905. })}
  906. inline={false}
  907. styles={{
  908. control: provided => ({
  909. ...provided,
  910. minHeight: '20px',
  911. height: '20px',
  912. }),
  913. }}
  914. isSearchable={false}
  915. isClearable={false}
  916. name="actionMatch"
  917. required
  918. flexibleControlStateSize
  919. options={ACTION_MATCH_OPTIONS_MIGRATED}
  920. onChange={val =>
  921. this.handleChange('actionMatch', val)
  922. }
  923. disabled={disabled}
  924. />
  925. </EmbeddedWrapper>
  926. ),
  927. }
  928. )}
  929. </StepLead>
  930. <RuleNodeList
  931. nodes={this.getConditions()}
  932. items={conditions ?? []}
  933. selectType="grouped"
  934. placeholder={t('Add optional trigger...')}
  935. onPropertyChange={this.handleChangeConditionProperty}
  936. onAddRow={this.handleAddCondition}
  937. onResetRow={this.handleResetCondition}
  938. onDeleteRow={this.handleDeleteCondition}
  939. organization={organization}
  940. project={project}
  941. disabled={disabled}
  942. error={
  943. this.hasError('conditions') && (
  944. <StyledAlert type="error">
  945. {detailedError?.conditions[0]}
  946. </StyledAlert>
  947. )
  948. }
  949. />
  950. </StepContent>
  951. </StepContainer>
  952. </Step>
  953. <Step>
  954. <StepConnector />
  955. <StepContainer>
  956. <ChevronContainer>
  957. <IconChevron
  958. color="gray200"
  959. isCircled
  960. direction="right"
  961. size="sm"
  962. />
  963. </ChevronContainer>
  964. <StepContent>
  965. <StepLead>
  966. {tct('[if:If] [selector] of these filters match', {
  967. if: <Badge />,
  968. selector: (
  969. <EmbeddedWrapper>
  970. <EmbeddedSelectField
  971. className={classNames({
  972. error: this.hasError('filterMatch'),
  973. })}
  974. inline={false}
  975. styles={{
  976. control: provided => ({
  977. ...provided,
  978. minHeight: '20px',
  979. height: '20px',
  980. }),
  981. }}
  982. isSearchable={false}
  983. isClearable={false}
  984. name="filterMatch"
  985. required
  986. flexibleControlStateSize
  987. options={ACTION_MATCH_OPTIONS}
  988. onChange={val =>
  989. this.handleChange('filterMatch', val)
  990. }
  991. disabled={disabled}
  992. />
  993. </EmbeddedWrapper>
  994. ),
  995. })}
  996. </StepLead>
  997. <RuleNodeList
  998. nodes={this.state.configs?.filters ?? null}
  999. items={filters ?? []}
  1000. placeholder={t('Add optional filter...')}
  1001. onPropertyChange={this.handleChangeFilterProperty}
  1002. onAddRow={this.handleAddFilter}
  1003. onResetRow={this.handleResetFilter}
  1004. onDeleteRow={this.handleDeleteFilter}
  1005. organization={organization}
  1006. project={project}
  1007. disabled={disabled}
  1008. error={
  1009. this.hasError('filters') && (
  1010. <StyledAlert type="error">
  1011. {detailedError?.filters[0]}
  1012. </StyledAlert>
  1013. )
  1014. }
  1015. />
  1016. </StepContent>
  1017. </StepContainer>
  1018. </Step>
  1019. <Step>
  1020. <StepContainer>
  1021. <ChevronContainer>
  1022. <IconChevron
  1023. isCircled
  1024. color="gray200"
  1025. direction="right"
  1026. size="sm"
  1027. />
  1028. </ChevronContainer>
  1029. <StepContent>
  1030. <StepLead>
  1031. {tct('[then:Then] perform these actions', {
  1032. then: <Badge />,
  1033. })}
  1034. </StepLead>
  1035. <RuleNodeList
  1036. nodes={this.state.configs?.actions ?? null}
  1037. selectType="grouped"
  1038. items={actions ?? []}
  1039. placeholder={t('Add action...')}
  1040. onPropertyChange={this.handleChangeActionProperty}
  1041. onAddRow={this.handleAddAction}
  1042. onResetRow={this.handleResetAction}
  1043. onDeleteRow={this.handleDeleteAction}
  1044. organization={organization}
  1045. project={project}
  1046. disabled={disabled}
  1047. error={
  1048. this.hasError('actions') && (
  1049. <StyledAlert type="error">
  1050. {detailedError?.actions[0]}
  1051. </StyledAlert>
  1052. )
  1053. }
  1054. />
  1055. </StepContent>
  1056. </StepContainer>
  1057. </Step>
  1058. </PanelBody>
  1059. </ConditionsPanel>
  1060. <StyledListItem>
  1061. {t('Set action interval')}
  1062. <StyledFieldHelp>
  1063. {t('Perform the actions above once this often for an issue')}
  1064. </StyledFieldHelp>
  1065. </StyledListItem>
  1066. {this.hasAlertWizardV3 ? (
  1067. this.renderActionInterval(disabled)
  1068. ) : (
  1069. <Panel>
  1070. <PanelBody>{this.renderActionInterval(disabled)}</PanelBody>
  1071. </Panel>
  1072. )}
  1073. {this.hasAlertWizardV3 && (
  1074. <Fragment>
  1075. <StyledListItem>{t('Establish ownership')}</StyledListItem>
  1076. {this.renderRuleName(disabled)}
  1077. {this.renderTeamSelect(disabled)}
  1078. </Fragment>
  1079. )}
  1080. </List>
  1081. </StyledForm>
  1082. </Main>
  1083. );
  1084. }}
  1085. </Access>
  1086. );
  1087. }
  1088. }
  1089. export default withExperiment(withOrganization(withProjects(IssueRuleEditor)), {
  1090. experiment: 'DefaultIssueAlertActionExperiment',
  1091. injectLogExperiment: true,
  1092. });
  1093. // TODO(ts): Understand why styled is not correctly inheriting props here
  1094. const StyledForm = styled(Form)<Form['props']>`
  1095. position: relative;
  1096. `;
  1097. const ConditionsPanel = styled(Panel)`
  1098. padding-top: ${space(0.5)};
  1099. padding-bottom: ${space(2)};
  1100. `;
  1101. const StyledAlert = styled(Alert)`
  1102. margin-bottom: 0;
  1103. `;
  1104. const StyledListItem = styled(ListItem)`
  1105. margin: ${space(2)} 0 ${space(1)} 0;
  1106. font-size: ${p => p.theme.fontSizeExtraLarge};
  1107. `;
  1108. const StyledFieldHelp = styled(FieldHelp)`
  1109. margin-top: 0;
  1110. `;
  1111. const SetConditionsListItem = styled(StyledListItem)`
  1112. display: flex;
  1113. justify-content: space-between;
  1114. `;
  1115. const Step = styled('div')`
  1116. position: relative;
  1117. display: flex;
  1118. align-items: flex-start;
  1119. margin: ${space(4)} ${space(4)} ${space(3)} ${space(1)};
  1120. `;
  1121. const StepContainer = styled('div')`
  1122. position: relative;
  1123. display: flex;
  1124. align-items: flex-start;
  1125. flex-grow: 1;
  1126. `;
  1127. const StepContent = styled('div')`
  1128. flex-grow: 1;
  1129. `;
  1130. const StepConnector = styled('div')`
  1131. position: absolute;
  1132. height: 100%;
  1133. top: 28px;
  1134. left: 19px;
  1135. border-right: 1px ${p => p.theme.gray200} dashed;
  1136. `;
  1137. const StepLead = styled('div')`
  1138. margin-bottom: ${space(0.5)};
  1139. `;
  1140. const ChevronContainer = styled('div')`
  1141. display: flex;
  1142. align-items: center;
  1143. padding: ${space(0.5)} ${space(1.5)};
  1144. `;
  1145. const Badge = styled('span')`
  1146. display: inline-block;
  1147. min-width: 56px;
  1148. background-color: ${p => p.theme.purple300};
  1149. padding: 0 ${space(0.75)};
  1150. border-radius: ${p => p.theme.borderRadius};
  1151. color: ${p => p.theme.white};
  1152. text-transform: uppercase;
  1153. text-align: center;
  1154. font-size: ${p => p.theme.fontSizeMedium};
  1155. font-weight: 600;
  1156. line-height: 1.5;
  1157. `;
  1158. const EmbeddedWrapper = styled('div')`
  1159. display: inline-block;
  1160. margin: 0 ${space(0.5)};
  1161. width: 80px;
  1162. `;
  1163. const EmbeddedSelectField = styled(SelectField)`
  1164. padding: 0;
  1165. font-weight: normal;
  1166. text-transform: none;
  1167. `;
  1168. const SemiTransparentLoadingMask = styled(LoadingMask)`
  1169. opacity: 0.6;
  1170. z-index: 1; /* Needed so that it sits above form elements */
  1171. `;
  1172. const SettingsContainer = styled('div')`
  1173. display: grid;
  1174. grid-template-columns: 1fr 1fr;
  1175. gap: ${space(1)};
  1176. `;
  1177. const StyledField = styled(Field)<{extraMargin?: boolean; hasAlertWizardV3?: boolean}>`
  1178. :last-child {
  1179. padding-bottom: ${space(2)};
  1180. }
  1181. ${p =>
  1182. p.hasAlertWizardV3 &&
  1183. `
  1184. border-bottom: none;
  1185. padding: 0;
  1186. & > div {
  1187. padding: 0;
  1188. width: 100%;
  1189. }
  1190. margin-bottom: ${p.extraMargin ? '60px' : space(1)};
  1191. `}
  1192. `;
  1193. const StyledSelectField = styled(SelectField)<{hasAlertWizardV3?: boolean}>`
  1194. ${p =>
  1195. p.hasAlertWizardV3 &&
  1196. `
  1197. border-bottom: none;
  1198. padding: 0;
  1199. & > div {
  1200. padding: 0;
  1201. width: 100%;
  1202. }
  1203. margin-bottom: ${space(1)};
  1204. `}
  1205. `;
  1206. const Main = styled(Layout.Main)`
  1207. padding: ${space(2)} ${space(4)};
  1208. `;