index.tsx 57 KB

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