index.tsx 60 KB

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