index.tsx 41 KB

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