index.tsx 41 KB

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