index.tsx 50 KB

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