index.tsx 44 KB

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