index.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  1. import * as React from 'react';
  2. import {browserHistory, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import classNames from 'classnames';
  5. import cloneDeep from 'lodash/cloneDeep';
  6. import omit from 'lodash/omit';
  7. import set from 'lodash/set';
  8. import {
  9. addErrorMessage,
  10. addLoadingMessage,
  11. addSuccessMessage,
  12. } from 'app/actionCreators/indicator';
  13. import {updateOnboardingTask} from 'app/actionCreators/onboardingTasks';
  14. import Access from 'app/components/acl/access';
  15. import Feature from 'app/components/acl/feature';
  16. import Alert from 'app/components/alert';
  17. import Button from 'app/components/button';
  18. import Confirm from 'app/components/confirm';
  19. import TeamSelector from 'app/components/forms/teamSelector';
  20. import List from 'app/components/list';
  21. import ListItem from 'app/components/list/listItem';
  22. import LoadingMask from 'app/components/loadingMask';
  23. import {Panel, PanelBody} from 'app/components/panels';
  24. import {ALL_ENVIRONMENTS_KEY} from 'app/constants';
  25. import {IconChevron, IconWarning} from 'app/icons';
  26. import {t, tct} from 'app/locale';
  27. import space from 'app/styles/space';
  28. import {Environment, OnboardingTaskKey, Organization, Project, Team} from 'app/types';
  29. import {
  30. IssueAlertRule,
  31. IssueAlertRuleAction,
  32. IssueAlertRuleActionTemplate,
  33. IssueAlertRuleConditionTemplate,
  34. UnsavedIssueAlertRule,
  35. } from 'app/types/alerts';
  36. import {metric, trackAnalyticsEvent} from 'app/utils/analytics';
  37. import {getDisplayName} from 'app/utils/environment';
  38. import {isActiveSuperuser} from 'app/utils/isActiveSuperuser';
  39. import recreateRoute from 'app/utils/recreateRoute';
  40. import routeTitleGen from 'app/utils/routeTitle';
  41. import withOrganization from 'app/utils/withOrganization';
  42. import withTeams from 'app/utils/withTeams';
  43. import {
  44. CHANGE_ALERT_CONDITION_IDS,
  45. CHANGE_ALERT_PLACEHOLDERS_LABELS,
  46. } from 'app/views/alerts/changeAlerts/constants';
  47. import AsyncView from 'app/views/asyncView';
  48. import Input from 'app/views/settings/components/forms/controls/input';
  49. import Field from 'app/views/settings/components/forms/field';
  50. import Form from 'app/views/settings/components/forms/form';
  51. import SelectField from 'app/views/settings/components/forms/selectField';
  52. import RuleNodeList from './ruleNodeList';
  53. import SetupAlertIntegrationButton from './setupAlertIntegrationButton';
  54. const FREQUENCY_OPTIONS = [
  55. {value: '5', label: t('5 minutes')},
  56. {value: '10', label: t('10 minutes')},
  57. {value: '30', label: t('30 minutes')},
  58. {value: '60', label: t('60 minutes')},
  59. {value: '180', label: t('3 hours')},
  60. {value: '720', label: t('12 hours')},
  61. {value: '1440', label: t('24 hours')},
  62. {value: '10080', label: t('1 week')},
  63. {value: '43200', label: t('30 days')},
  64. ];
  65. const ACTION_MATCH_OPTIONS = [
  66. {value: 'all', label: t('all')},
  67. {value: 'any', label: t('any')},
  68. {value: 'none', label: t('none')},
  69. ];
  70. const ACTION_MATCH_OPTIONS_MIGRATED = [
  71. {value: 'all', label: t('all')},
  72. {value: 'any', label: t('any')},
  73. ];
  74. const defaultRule: UnsavedIssueAlertRule = {
  75. actionMatch: 'all',
  76. filterMatch: 'all',
  77. actions: [],
  78. conditions: [],
  79. filters: [],
  80. name: '',
  81. frequency: 30,
  82. environment: ALL_ENVIRONMENTS_KEY,
  83. };
  84. const POLLING_MAX_TIME_LIMIT = 3 * 60000;
  85. type ConditionOrActionProperty = 'conditions' | 'actions' | 'filters';
  86. type RuleTaskResponse = {
  87. status: 'pending' | 'failed' | 'success';
  88. rule?: IssueAlertRule;
  89. error?: string;
  90. };
  91. type Props = {
  92. project: Project;
  93. organization: Organization;
  94. teams: Team[];
  95. onChangeTitle?: (data: string) => void;
  96. } & RouteComponentProps<{orgId: string; projectId: string; ruleId?: string}, {}>;
  97. type State = AsyncView['state'] & {
  98. detailedError: null | {
  99. [key: string]: string[];
  100. };
  101. environments: Environment[] | null;
  102. configs: {
  103. actions: IssueAlertRuleActionTemplate[];
  104. filters: IssueAlertRuleConditionTemplate[];
  105. conditions: IssueAlertRuleConditionTemplate[];
  106. } | null;
  107. uuid: null | string;
  108. rule?: UnsavedIssueAlertRule | IssueAlertRule | null;
  109. };
  110. function isSavedAlertRule(rule: State['rule']): rule is IssueAlertRule {
  111. return rule?.hasOwnProperty('id') ?? false;
  112. }
  113. class IssueRuleEditor extends AsyncView<Props, State> {
  114. getTitle() {
  115. const {organization, project} = this.props;
  116. const {rule} = this.state;
  117. const ruleName = rule?.name;
  118. return routeTitleGen(
  119. ruleName ? t('Alert %s', ruleName) : '',
  120. organization.slug,
  121. false,
  122. project?.slug
  123. );
  124. }
  125. getDefaultState() {
  126. const {teams, project} = this.props;
  127. const defaultState = {
  128. ...super.getDefaultState(),
  129. configs: null,
  130. detailedError: null,
  131. rule: {...defaultRule},
  132. environments: [],
  133. uuid: null,
  134. };
  135. const projectTeamIds = new Set(project.teams.map(({id}) => id));
  136. const userTeam =
  137. teams.find(({isMember, id}) => !!isMember && projectTeamIds.has(id)) ?? null;
  138. defaultState.rule.owner = userTeam && `team:${userTeam.id}`;
  139. return defaultState;
  140. }
  141. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  142. const {ruleId, projectId, orgId} = this.props.params;
  143. const endpoints = [
  144. ['environments', `/projects/${orgId}/${projectId}/environments/`],
  145. ['configs', `/projects/${orgId}/${projectId}/rules/configuration/`],
  146. ];
  147. if (ruleId) {
  148. endpoints.push(['rule', `/projects/${orgId}/${projectId}/rules/${ruleId}/`]);
  149. }
  150. return endpoints as [string, string][];
  151. }
  152. onRequestSuccess({stateKey, data}) {
  153. if (stateKey === 'rule' && data.name) {
  154. this.props.onChangeTitle?.(data.name);
  155. }
  156. }
  157. pollHandler = async (quitTime: number) => {
  158. if (Date.now() > quitTime) {
  159. addErrorMessage(t('Looking for that channel took too long :('));
  160. this.setState({loading: false});
  161. return;
  162. }
  163. const {organization, project} = this.props;
  164. const {uuid} = this.state;
  165. const origRule = this.state.rule;
  166. try {
  167. const response: RuleTaskResponse = await this.api.requestPromise(
  168. `/projects/${organization.slug}/${project.slug}/rule-task/${uuid}/`
  169. );
  170. const {status, rule, error} = response;
  171. if (status === 'pending') {
  172. setTimeout(() => {
  173. this.pollHandler(quitTime);
  174. }, 1000);
  175. return;
  176. }
  177. if (status === 'failed') {
  178. this.setState({
  179. detailedError: {actions: [error ? error : t('An error occurred')]},
  180. loading: false,
  181. });
  182. this.handleRuleSaveFailure(t('An error occurred'));
  183. }
  184. if (rule) {
  185. const ruleId = isSavedAlertRule(origRule) ? `${origRule.id}/` : '';
  186. const isNew = !ruleId;
  187. this.handleRuleSuccess(isNew, rule);
  188. }
  189. } catch {
  190. this.handleRuleSaveFailure(t('An error occurred'));
  191. this.setState({loading: false});
  192. }
  193. };
  194. fetchStatus() {
  195. // pollHandler calls itself until it gets either a success
  196. // or failed status but we don't want to poll forever so we pass
  197. // in a hard stop time of 3 minutes before we bail.
  198. const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
  199. setTimeout(() => {
  200. this.pollHandler(quitTime);
  201. }, 1000);
  202. }
  203. handleRuleSuccess = (isNew: boolean, rule: IssueAlertRule) => {
  204. const {organization, router} = this.props;
  205. this.setState({detailedError: null, loading: false, rule});
  206. // The onboarding task will be completed on the server side when the alert
  207. // is created
  208. updateOnboardingTask(null, organization, {
  209. task: OnboardingTaskKey.ALERT_RULE,
  210. status: 'complete',
  211. });
  212. metric.endTransaction({name: 'saveAlertRule'});
  213. router.push(`/organizations/${organization.slug}/alerts/rules/`);
  214. addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
  215. };
  216. handleRuleSaveFailure(msg: React.ReactNode) {
  217. addErrorMessage(msg);
  218. metric.endTransaction({name: 'saveAlertRule'});
  219. }
  220. handleSubmit = async () => {
  221. const {rule} = this.state;
  222. const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
  223. const isNew = !ruleId;
  224. const {project, organization} = this.props;
  225. const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
  226. if (rule && rule.environment === ALL_ENVIRONMENTS_KEY) {
  227. delete rule.environment;
  228. }
  229. addLoadingMessage();
  230. try {
  231. const transaction = metric.startTransaction({name: 'saveAlertRule'});
  232. transaction.setTag('type', 'issue');
  233. transaction.setTag('operation', isNew ? 'create' : 'edit');
  234. if (rule) {
  235. for (const action of rule.actions) {
  236. // Grab the last part of something like 'sentry.mail.actions.NotifyEmailAction'
  237. const splitActionId = action.id.split('.');
  238. const actionName = splitActionId[splitActionId.length - 1];
  239. if (actionName === 'SlackNotifyServiceAction') {
  240. transaction.setTag(actionName, true);
  241. }
  242. }
  243. transaction.setData('actions', rule.actions);
  244. }
  245. const [data, , resp] = await this.api.requestPromise(endpoint, {
  246. includeAllArgs: true,
  247. method: isNew ? 'POST' : 'PUT',
  248. data: rule,
  249. });
  250. // if we get a 202 back it means that we have an async task
  251. // running to lookup and verify the channel id for Slack.
  252. if (resp?.status === 202) {
  253. this.setState({detailedError: null, loading: true, uuid: data.uuid});
  254. this.fetchStatus();
  255. addLoadingMessage(t('Looking through all your channels...'));
  256. } else {
  257. this.handleRuleSuccess(isNew, data);
  258. }
  259. } catch (err) {
  260. this.setState({
  261. detailedError: err.responseJSON || {__all__: 'Unknown error'},
  262. loading: false,
  263. });
  264. this.handleRuleSaveFailure(t('An error occurred'));
  265. }
  266. };
  267. handleDeleteRule = async () => {
  268. const {rule} = this.state;
  269. const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
  270. const isNew = !ruleId;
  271. const {project, organization} = this.props;
  272. if (isNew) {
  273. return;
  274. }
  275. const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
  276. addLoadingMessage(t('Deleting...'));
  277. try {
  278. await this.api.requestPromise(endpoint, {
  279. method: 'DELETE',
  280. });
  281. addSuccessMessage(t('Deleted alert rule'));
  282. browserHistory.replace(recreateRoute('', {...this.props, stepBack: -2}));
  283. } catch (err) {
  284. this.setState({
  285. detailedError: err.responseJSON || {__all__: 'Unknown error'},
  286. });
  287. addErrorMessage(t('There was a problem deleting the alert'));
  288. }
  289. };
  290. handleCancel = () => {
  291. const {organization, router} = this.props;
  292. router.push(`/organizations/${organization.slug}/alerts/rules/`);
  293. };
  294. hasError = (field: string) => {
  295. const {detailedError} = this.state;
  296. if (!detailedError) {
  297. return false;
  298. }
  299. return detailedError.hasOwnProperty(field);
  300. };
  301. handleEnvironmentChange = (val: string) => {
  302. // If 'All Environments' is selected the value should be null
  303. if (val === ALL_ENVIRONMENTS_KEY) {
  304. this.handleChange('environment', null);
  305. } else {
  306. this.handleChange('environment', val);
  307. }
  308. };
  309. handleChange = <T extends keyof IssueAlertRule>(prop: T, val: IssueAlertRule[T]) => {
  310. this.setState(prevState => {
  311. const clonedState = cloneDeep(prevState);
  312. set(clonedState, `rule[${prop}]`, val);
  313. return {...clonedState, detailedError: omit(prevState.detailedError, prop)};
  314. });
  315. };
  316. handlePropertyChange = <T extends keyof IssueAlertRuleAction>(
  317. type: ConditionOrActionProperty,
  318. idx: number,
  319. prop: T,
  320. val: IssueAlertRuleAction[T]
  321. ) => {
  322. this.setState(prevState => {
  323. const clonedState = cloneDeep(prevState);
  324. set(clonedState, `rule[${type}][${idx}][${prop}]`, val);
  325. return clonedState;
  326. });
  327. };
  328. getInitialValue = (type: ConditionOrActionProperty, id: string) => {
  329. const configuration = this.state.configs?.[type]?.find(c => c.id === id);
  330. const hasChangeAlerts =
  331. configuration?.id &&
  332. this.props.organization.features.includes('change-alerts') &&
  333. CHANGE_ALERT_CONDITION_IDS.includes(configuration.id);
  334. return configuration?.formFields
  335. ? Object.fromEntries(
  336. Object.entries(configuration.formFields)
  337. // TODO(ts): Doesn't work if I cast formField as IssueAlertRuleFormField
  338. .map(([key, formField]: [string, any]) => [
  339. key,
  340. hasChangeAlerts && key === 'interval'
  341. ? '1h'
  342. : formField?.initial ?? formField?.choices?.[0]?.[0],
  343. ])
  344. .filter(([, initial]) => !!initial)
  345. )
  346. : {};
  347. };
  348. handleResetRow = <T extends keyof IssueAlertRuleAction>(
  349. type: ConditionOrActionProperty,
  350. idx: number,
  351. prop: T,
  352. val: IssueAlertRuleAction[T]
  353. ) => {
  354. this.setState(prevState => {
  355. const clonedState = cloneDeep(prevState);
  356. // Set initial configuration, but also set
  357. const id = (clonedState.rule as IssueAlertRule)[type][idx].id;
  358. const newRule = {
  359. ...this.getInitialValue(type, id),
  360. id,
  361. [prop]: val,
  362. };
  363. set(clonedState, `rule[${type}][${idx}]`, newRule);
  364. return clonedState;
  365. });
  366. };
  367. handleAddRow = (type: ConditionOrActionProperty, id: string) => {
  368. this.setState(prevState => {
  369. const clonedState = cloneDeep(prevState);
  370. // Set initial configuration
  371. const newRule = {
  372. ...this.getInitialValue(type, id),
  373. id,
  374. };
  375. const newTypeList = prevState.rule ? prevState.rule[type] : [];
  376. set(clonedState, `rule[${type}]`, [...newTypeList, newRule]);
  377. return clonedState;
  378. });
  379. const {organization, project} = this.props;
  380. trackAnalyticsEvent({
  381. eventKey: 'edit_alert_rule.add_row',
  382. eventName: 'Edit Alert Rule: Add Row',
  383. organization_id: organization.id,
  384. project_id: project.id,
  385. type,
  386. name: id,
  387. });
  388. };
  389. handleDeleteRow = (type: ConditionOrActionProperty, idx: number) => {
  390. this.setState(prevState => {
  391. const clonedState = cloneDeep(prevState);
  392. const newTypeList = prevState.rule ? prevState.rule[type] : [];
  393. if (prevState.rule) {
  394. newTypeList.splice(idx, 1);
  395. }
  396. set(clonedState, `rule[${type}]`, newTypeList);
  397. return clonedState;
  398. });
  399. };
  400. handleAddCondition = (id: string) => this.handleAddRow('conditions', id);
  401. handleAddAction = (id: string) => this.handleAddRow('actions', id);
  402. handleAddFilter = (id: string) => this.handleAddRow('filters', id);
  403. handleDeleteCondition = (ruleIndex: number) =>
  404. this.handleDeleteRow('conditions', ruleIndex);
  405. handleDeleteAction = (ruleIndex: number) => this.handleDeleteRow('actions', ruleIndex);
  406. handleDeleteFilter = (ruleIndex: number) => this.handleDeleteRow('filters', ruleIndex);
  407. handleChangeConditionProperty = (ruleIndex: number, prop: string, val: string) =>
  408. this.handlePropertyChange('conditions', ruleIndex, prop, val);
  409. handleChangeActionProperty = (ruleIndex: number, prop: string, val: string) =>
  410. this.handlePropertyChange('actions', ruleIndex, prop, val);
  411. handleChangeFilterProperty = (ruleIndex: number, prop: string, val: string) =>
  412. this.handlePropertyChange('filters', ruleIndex, prop, val);
  413. handleResetCondition = (ruleIndex: number, prop: string, value: string) =>
  414. this.handleResetRow('conditions', ruleIndex, prop, value);
  415. handleResetAction = (ruleIndex: number, prop: string, value: string) =>
  416. this.handleResetRow('actions', ruleIndex, prop, value);
  417. handleResetFilter = (ruleIndex: number, prop: string, value: string) =>
  418. this.handleResetRow('filters', ruleIndex, prop, value);
  419. handleValidateRuleName = () => {
  420. const isRuleNameEmpty = !this.state.rule?.name.trim();
  421. if (!isRuleNameEmpty) {
  422. return;
  423. }
  424. this.setState(prevState => ({
  425. detailedError: {
  426. ...prevState.detailedError,
  427. name: [t('Field Required')],
  428. },
  429. }));
  430. };
  431. getConditions() {
  432. const {organization} = this.props;
  433. if (!organization.features.includes('change-alerts')) {
  434. return this.state.configs?.conditions ?? null;
  435. }
  436. return (
  437. this.state.configs?.conditions?.map(condition =>
  438. CHANGE_ALERT_CONDITION_IDS.includes(condition.id)
  439. ? ({
  440. ...condition,
  441. label: CHANGE_ALERT_PLACEHOLDERS_LABELS[condition.id],
  442. } as IssueAlertRuleConditionTemplate)
  443. : condition
  444. ) ?? null
  445. );
  446. }
  447. getTeamId = () => {
  448. const {rule} = this.state;
  449. const owner = rule?.owner;
  450. // ownership follows the format team:<id>, just grab the id
  451. return owner && owner.split(':')[1];
  452. };
  453. handleOwnerChange = ({value}: {value: string; label: string}) => {
  454. const ownerValue = value && `team:${value}`;
  455. this.handleChange('owner', ownerValue);
  456. };
  457. renderLoading() {
  458. return this.renderBody();
  459. }
  460. renderError() {
  461. return (
  462. <Alert type="error" icon={<IconWarning />}>
  463. {t(
  464. 'Unable to access this alert rule -- check to make sure you have the correct permissions'
  465. )}
  466. </Alert>
  467. );
  468. }
  469. renderBody() {
  470. const {project, organization, teams} = this.props;
  471. const {environments} = this.state;
  472. const environmentOptions = [
  473. {
  474. value: ALL_ENVIRONMENTS_KEY,
  475. label: t('All Environments'),
  476. },
  477. ...(environments?.map(env => ({value: env.name, label: getDisplayName(env)})) ??
  478. []),
  479. ];
  480. const {rule, detailedError} = this.state;
  481. const {actions, filters, conditions, frequency, name} = rule || {};
  482. const environment =
  483. !rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
  484. const userTeams = teams.filter(({isMember}) => isMember).map(({id}) => id);
  485. const ownerId = rule?.owner?.split(':')[1];
  486. // check if superuser or if user is on the alert's team
  487. const canEdit = isActiveSuperuser() || (ownerId ? userTeams.includes(ownerId) : true);
  488. // Note `key` on `<Form>` below is so that on initial load, we show
  489. // the form with a loading mask on top of it, but force a re-render by using
  490. // a different key when we have fetched the rule so that form inputs are filled in
  491. return (
  492. <Access access={['alerts:write']}>
  493. {({hasAccess}) => (
  494. <StyledForm
  495. key={isSavedAlertRule(rule) ? rule.id : undefined}
  496. onCancel={this.handleCancel}
  497. onSubmit={this.handleSubmit}
  498. initialData={{
  499. ...rule,
  500. environment,
  501. frequency: `${frequency}`,
  502. }}
  503. submitDisabled={!hasAccess || !canEdit}
  504. submitLabel={isSavedAlertRule(rule) ? t('Save Rule') : t('Save Rule')}
  505. extraButton={
  506. isSavedAlertRule(rule) ? (
  507. <Confirm
  508. disabled={!hasAccess || !canEdit}
  509. priority="danger"
  510. confirmText={t('Delete Rule')}
  511. onConfirm={this.handleDeleteRule}
  512. header={t('Delete Rule')}
  513. message={t('Are you sure you want to delete this rule?')}
  514. >
  515. <Button priority="danger" type="button">
  516. {t('Delete Rule')}
  517. </Button>
  518. </Confirm>
  519. ) : null
  520. }
  521. >
  522. <List symbol="colored-numeric">
  523. {this.state.loading && <SemiTransparentLoadingMask />}
  524. <StyledListItem>{t('Add alert settings')}</StyledListItem>
  525. <Panel>
  526. <PanelBody>
  527. <SelectField
  528. className={classNames({
  529. error: this.hasError('environment'),
  530. })}
  531. label={t('Environment')}
  532. help={t('Choose an environment for these conditions to apply to')}
  533. placeholder={t('Select an Environment')}
  534. clearable={false}
  535. name="environment"
  536. options={environmentOptions}
  537. onChange={val => this.handleEnvironmentChange(val)}
  538. disabled={!hasAccess || !canEdit}
  539. />
  540. <StyledField
  541. label={t('Team')}
  542. help={t('The team that can edit this alert.')}
  543. disabled={!hasAccess || !canEdit}
  544. >
  545. <TeamSelector
  546. value={this.getTeamId()}
  547. project={project}
  548. onChange={this.handleOwnerChange}
  549. teamFilter={(team: Team) => team.isMember || team.id === ownerId}
  550. useId
  551. includeUnassigned
  552. disabled={!hasAccess || !canEdit}
  553. />
  554. </StyledField>
  555. <StyledField
  556. label={t('Alert name')}
  557. help={t('Add a name for this alert')}
  558. error={detailedError?.name?.[0]}
  559. disabled={!hasAccess || !canEdit}
  560. required
  561. stacked
  562. >
  563. <Input
  564. type="text"
  565. name="name"
  566. value={name}
  567. placeholder={t('My Rule Name')}
  568. onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
  569. this.handleChange('name', event.target.value)
  570. }
  571. onBlur={this.handleValidateRuleName}
  572. disabled={!hasAccess || !canEdit}
  573. />
  574. </StyledField>
  575. </PanelBody>
  576. </Panel>
  577. <SetConditionsListItem>
  578. {t('Set conditions')}
  579. <SetupAlertIntegrationButton
  580. projectSlug={project.slug}
  581. organization={organization}
  582. />
  583. </SetConditionsListItem>
  584. <ConditionsPanel>
  585. <PanelBody>
  586. <Step>
  587. <StepConnector />
  588. <StepContainer>
  589. <ChevronContainer>
  590. <IconChevron
  591. color="gray200"
  592. isCircled
  593. direction="right"
  594. size="sm"
  595. />
  596. </ChevronContainer>
  597. <Feature features={['projects:alert-filters']} project={project}>
  598. {({hasFeature}) => (
  599. <StepContent>
  600. <StepLead>
  601. {tct(
  602. '[when:When] an event is captured by Sentry and [selector] of the following happens',
  603. {
  604. when: <Badge />,
  605. selector: (
  606. <EmbeddedWrapper>
  607. <EmbeddedSelectField
  608. className={classNames({
  609. error: this.hasError('actionMatch'),
  610. })}
  611. inline={false}
  612. styles={{
  613. control: provided => ({
  614. ...provided,
  615. minHeight: '20px',
  616. height: '20px',
  617. }),
  618. }}
  619. isSearchable={false}
  620. isClearable={false}
  621. name="actionMatch"
  622. required
  623. flexibleControlStateSize
  624. options={
  625. hasFeature
  626. ? ACTION_MATCH_OPTIONS_MIGRATED
  627. : ACTION_MATCH_OPTIONS
  628. }
  629. onChange={val =>
  630. this.handleChange('actionMatch', val)
  631. }
  632. disabled={!hasAccess || !canEdit}
  633. />
  634. </EmbeddedWrapper>
  635. ),
  636. }
  637. )}
  638. </StepLead>
  639. <RuleNodeList
  640. nodes={this.getConditions()}
  641. items={conditions ?? []}
  642. placeholder={
  643. hasFeature
  644. ? t('Add optional trigger...')
  645. : t('Add optional condition...')
  646. }
  647. onPropertyChange={this.handleChangeConditionProperty}
  648. onAddRow={this.handleAddCondition}
  649. onResetRow={this.handleResetCondition}
  650. onDeleteRow={this.handleDeleteCondition}
  651. organization={organization}
  652. project={project}
  653. disabled={!hasAccess || !canEdit}
  654. error={
  655. this.hasError('conditions') && (
  656. <StyledAlert type="error">
  657. {detailedError?.conditions[0]}
  658. </StyledAlert>
  659. )
  660. }
  661. />
  662. </StepContent>
  663. )}
  664. </Feature>
  665. </StepContainer>
  666. </Step>
  667. <Feature
  668. features={['organizations:alert-filters', 'projects:alert-filters']}
  669. organization={organization}
  670. project={project}
  671. requireAll={false}
  672. >
  673. <Step>
  674. <StepConnector />
  675. <StepContainer>
  676. <ChevronContainer>
  677. <IconChevron
  678. color="gray200"
  679. isCircled
  680. direction="right"
  681. size="sm"
  682. />
  683. </ChevronContainer>
  684. <StepContent>
  685. <StepLead>
  686. {tct('[if:If] [selector] of these filters match', {
  687. if: <Badge />,
  688. selector: (
  689. <EmbeddedWrapper>
  690. <EmbeddedSelectField
  691. className={classNames({
  692. error: this.hasError('filterMatch'),
  693. })}
  694. inline={false}
  695. styles={{
  696. control: provided => ({
  697. ...provided,
  698. minHeight: '20px',
  699. height: '20px',
  700. }),
  701. }}
  702. isSearchable={false}
  703. isClearable={false}
  704. name="filterMatch"
  705. required
  706. flexibleControlStateSize
  707. options={ACTION_MATCH_OPTIONS}
  708. onChange={val =>
  709. this.handleChange('filterMatch', val)
  710. }
  711. disabled={!hasAccess || !canEdit}
  712. />
  713. </EmbeddedWrapper>
  714. ),
  715. })}
  716. </StepLead>
  717. <RuleNodeList
  718. nodes={this.state.configs?.filters ?? null}
  719. items={filters ?? []}
  720. placeholder={t('Add optional filter...')}
  721. onPropertyChange={this.handleChangeFilterProperty}
  722. onAddRow={this.handleAddFilter}
  723. onResetRow={this.handleResetFilter}
  724. onDeleteRow={this.handleDeleteFilter}
  725. organization={organization}
  726. project={project}
  727. disabled={!hasAccess || !canEdit}
  728. error={
  729. this.hasError('filters') && (
  730. <StyledAlert type="error">
  731. {detailedError?.filters[0]}
  732. </StyledAlert>
  733. )
  734. }
  735. />
  736. </StepContent>
  737. </StepContainer>
  738. </Step>
  739. </Feature>
  740. <Step>
  741. <StepContainer>
  742. <ChevronContainer>
  743. <IconChevron
  744. isCircled
  745. color="gray200"
  746. direction="right"
  747. size="sm"
  748. />
  749. </ChevronContainer>
  750. <StepContent>
  751. <StepLead>
  752. {tct('[then:Then] perform these actions', {
  753. then: <Badge />,
  754. })}
  755. </StepLead>
  756. <RuleNodeList
  757. nodes={this.state.configs?.actions ?? null}
  758. selectType="grouped"
  759. items={actions ?? []}
  760. placeholder={t('Add action...')}
  761. onPropertyChange={this.handleChangeActionProperty}
  762. onAddRow={this.handleAddAction}
  763. onResetRow={this.handleResetAction}
  764. onDeleteRow={this.handleDeleteAction}
  765. organization={organization}
  766. project={project}
  767. disabled={!hasAccess || !canEdit}
  768. error={
  769. this.hasError('actions') && (
  770. <StyledAlert type="error">
  771. {detailedError?.actions[0]}
  772. </StyledAlert>
  773. )
  774. }
  775. />
  776. </StepContent>
  777. </StepContainer>
  778. </Step>
  779. </PanelBody>
  780. </ConditionsPanel>
  781. <StyledListItem>{t('Set action interval')}</StyledListItem>
  782. <Panel>
  783. <PanelBody>
  784. <SelectField
  785. label={t('Action Interval')}
  786. help={t('Perform these actions once this often for an issue')}
  787. clearable={false}
  788. name="frequency"
  789. className={this.hasError('frequency') ? ' error' : ''}
  790. value={frequency}
  791. required
  792. options={FREQUENCY_OPTIONS}
  793. onChange={val => this.handleChange('frequency', val)}
  794. disabled={!hasAccess || !canEdit}
  795. />
  796. </PanelBody>
  797. </Panel>
  798. </List>
  799. </StyledForm>
  800. )}
  801. </Access>
  802. );
  803. }
  804. }
  805. export default withOrganization(withTeams(IssueRuleEditor));
  806. // TODO(ts): Understand why styled is not correctly inheriting props here
  807. const StyledForm = styled(Form)<Form['props']>`
  808. position: relative;
  809. `;
  810. const ConditionsPanel = styled(Panel)`
  811. padding-top: ${space(0.5)};
  812. padding-bottom: ${space(2)};
  813. `;
  814. const StyledAlert = styled(Alert)`
  815. margin-bottom: 0;
  816. `;
  817. const StyledListItem = styled(ListItem)`
  818. margin: ${space(2)} 0 ${space(1)} 0;
  819. font-size: ${p => p.theme.fontSizeExtraLarge};
  820. `;
  821. const SetConditionsListItem = styled(StyledListItem)`
  822. display: flex;
  823. justify-content: space-between;
  824. `;
  825. const Step = styled('div')`
  826. position: relative;
  827. display: flex;
  828. align-items: flex-start;
  829. margin: ${space(4)} ${space(4)} ${space(3)} ${space(1)};
  830. `;
  831. const StepContainer = styled('div')`
  832. position: relative;
  833. display: flex;
  834. align-items: flex-start;
  835. flex-grow: 1;
  836. `;
  837. const StepContent = styled('div')`
  838. flex-grow: 1;
  839. `;
  840. const StepConnector = styled('div')`
  841. position: absolute;
  842. height: 100%;
  843. top: 28px;
  844. left: 19px;
  845. border-right: 1px ${p => p.theme.gray300} dashed;
  846. `;
  847. const StepLead = styled('div')`
  848. margin-bottom: ${space(0.5)};
  849. `;
  850. const ChevronContainer = styled('div')`
  851. display: flex;
  852. align-items: center;
  853. padding: ${space(0.5)} ${space(1.5)};
  854. `;
  855. const Badge = styled('span')`
  856. display: inline-block;
  857. min-width: 56px;
  858. background-color: ${p => p.theme.purple300};
  859. padding: 0 ${space(0.75)};
  860. border-radius: ${p => p.theme.borderRadius};
  861. color: ${p => p.theme.white};
  862. text-transform: uppercase;
  863. text-align: center;
  864. font-size: ${p => p.theme.fontSizeMedium};
  865. font-weight: 600;
  866. line-height: 1.5;
  867. `;
  868. const EmbeddedWrapper = styled('div')`
  869. display: inline-block;
  870. margin: 0 ${space(0.5)};
  871. width: 80px;
  872. `;
  873. const EmbeddedSelectField = styled(SelectField)`
  874. padding: 0;
  875. font-weight: normal;
  876. text-transform: none;
  877. `;
  878. const SemiTransparentLoadingMask = styled(LoadingMask)`
  879. opacity: 0.6;
  880. z-index: 1; /* Needed so that it sits above form elements */
  881. `;
  882. const StyledField = styled(Field)`
  883. :last-child {
  884. padding-bottom: ${space(2)};
  885. }
  886. `;