index.tsx 44 KB

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