index.tsx 55 KB

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