issueAlertOptions.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import isEqual from 'lodash/isEqual';
  5. import AsyncComponent from 'sentry/components/asyncComponent';
  6. import RadioGroup from 'sentry/components/forms/controls/radioGroup';
  7. import SelectControl from 'sentry/components/forms/controls/selectControl';
  8. import Input from 'sentry/components/input';
  9. import * as Layout from 'sentry/components/layouts/thirds';
  10. import {t} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import {Organization} from 'sentry/types';
  13. import {IssueAlertRuleAction} from 'sentry/types/alerts';
  14. import withOrganization from 'sentry/utils/withOrganization';
  15. enum MetricValues {
  16. ERRORS,
  17. USERS,
  18. }
  19. enum Actions {
  20. ALERT_ON_EVERY_ISSUE,
  21. CUSTOMIZED_ALERTS,
  22. CREATE_ALERT_LATER,
  23. }
  24. const UNIQUE_USER_FREQUENCY_CONDITION =
  25. 'sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyCondition';
  26. const EVENT_FREQUENCY_CONDITION =
  27. 'sentry.rules.conditions.event_frequency.EventFrequencyCondition';
  28. export const EVENT_FREQUENCY_PERCENT_CONDITION =
  29. 'sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition';
  30. export const REAPPEARED_EVENT_CONDITION =
  31. 'sentry.rules.conditions.reappeared_event.ReappearedEventCondition';
  32. const ISSUE_ALERT_DEFAULT_ACTION: Omit<
  33. IssueAlertRuleAction,
  34. 'label' | 'name' | 'prompt'
  35. > = {
  36. id: 'sentry.mail.actions.NotifyEmailAction',
  37. targetType: 'IssueOwners',
  38. };
  39. const METRIC_CONDITION_MAP = {
  40. [MetricValues.ERRORS]: EVENT_FREQUENCY_CONDITION,
  41. [MetricValues.USERS]: UNIQUE_USER_FREQUENCY_CONDITION,
  42. } as const;
  43. const DEFAULT_PLACEHOLDER_VALUE = '10';
  44. type StateUpdater = (updatedData: RequestDataFragment) => void;
  45. type Props = AsyncComponent['props'] & {
  46. onChange: StateUpdater;
  47. organization: Organization;
  48. };
  49. type State = AsyncComponent['state'] & {
  50. alertSetting: string;
  51. // TODO(ts): When we have alert conditional types, convert this
  52. conditions: any;
  53. interval: string;
  54. intervalChoices: [string, string][] | undefined;
  55. metric: MetricValues;
  56. threshold: string;
  57. };
  58. type RequestDataFragment = {
  59. actionMatch: string;
  60. actions: Omit<IssueAlertRuleAction, 'label' | 'name' | 'prompt'>[];
  61. conditions: {id: string; interval: string; value: string}[] | undefined;
  62. defaultRules: boolean;
  63. frequency: number;
  64. name: string;
  65. shouldCreateCustomRule: boolean;
  66. };
  67. function getConditionFrom(
  68. interval: string,
  69. metricValue: MetricValues,
  70. threshold: string
  71. ): {id: string; interval: string; value: string} {
  72. let condition: string;
  73. switch (metricValue) {
  74. case MetricValues.ERRORS:
  75. condition = EVENT_FREQUENCY_CONDITION;
  76. break;
  77. case MetricValues.USERS:
  78. condition = UNIQUE_USER_FREQUENCY_CONDITION;
  79. break;
  80. default:
  81. throw new RangeError('Supplied metric value is not handled');
  82. }
  83. return {
  84. interval,
  85. id: condition,
  86. value: threshold,
  87. };
  88. }
  89. function unpackConditions(conditions: any[]) {
  90. const equalityReducer = (acc, curr) => {
  91. if (!acc || !curr || !isEqual(acc, curr)) {
  92. return null;
  93. }
  94. return acc;
  95. };
  96. const intervalChoices = conditions
  97. .map(condition => condition.formFields?.interval?.choices)
  98. .reduce(equalityReducer);
  99. return {intervalChoices, interval: intervalChoices?.[0]?.[0]};
  100. }
  101. class IssueAlertOptions extends AsyncComponent<Props, State> {
  102. getDefaultState(): State {
  103. return {
  104. ...super.getDefaultState(),
  105. conditions: [],
  106. intervalChoices: [],
  107. alertSetting: Actions.ALERT_ON_EVERY_ISSUE.toString(),
  108. metric: MetricValues.ERRORS,
  109. interval: '',
  110. threshold: '',
  111. };
  112. }
  113. getAvailableMetricOptions() {
  114. return [
  115. {value: MetricValues.ERRORS, label: t('occurrences of')},
  116. {value: MetricValues.USERS, label: t('users affected by')},
  117. ].filter(({value}) => {
  118. return this.state.conditions?.some?.(
  119. object => object?.id === METRIC_CONDITION_MAP[value]
  120. );
  121. });
  122. }
  123. getIssueAlertsChoices(
  124. hasProperlyLoadedConditions: boolean
  125. ): [string, string | React.ReactElement][] {
  126. const customizedAlertOption: [string, React.ReactNode] = [
  127. Actions.CUSTOMIZED_ALERTS.toString(),
  128. <CustomizeAlertsGrid
  129. key={Actions.CUSTOMIZED_ALERTS}
  130. onClick={e => {
  131. // XXX(epurkhiser): The `e.preventDefault` here is needed to stop
  132. // propagation of the click up to the label, causing it to focus
  133. // the radio input and lose focus on the select.
  134. e.preventDefault();
  135. const alertSetting = Actions.CUSTOMIZED_ALERTS.toString();
  136. this.setStateAndUpdateParents({alertSetting});
  137. }}
  138. >
  139. {t('When there are more than')}
  140. <InlineInput
  141. type="number"
  142. min="0"
  143. name=""
  144. placeholder={DEFAULT_PLACEHOLDER_VALUE}
  145. value={this.state.threshold}
  146. onChange={threshold =>
  147. this.setStateAndUpdateParents({threshold: threshold.target.value})
  148. }
  149. data-test-id="range-input"
  150. />
  151. <InlineSelectControl
  152. value={this.state.metric}
  153. options={this.getAvailableMetricOptions()}
  154. onChange={metric => this.setStateAndUpdateParents({metric: metric.value})}
  155. />
  156. {t('a unique error in')}
  157. <InlineSelectControl
  158. value={this.state.interval}
  159. options={this.state.intervalChoices?.map(([value, label]) => ({
  160. value,
  161. label,
  162. }))}
  163. onChange={interval => this.setStateAndUpdateParents({interval: interval.value})}
  164. />
  165. </CustomizeAlertsGrid>,
  166. ];
  167. const options: [string, React.ReactNode][] = [
  168. [Actions.ALERT_ON_EVERY_ISSUE.toString(), t('Alert me on every new issue')],
  169. ...(hasProperlyLoadedConditions ? [customizedAlertOption] : []),
  170. [Actions.CREATE_ALERT_LATER.toString(), t("I'll create my own alerts later")],
  171. ];
  172. return options.map(([choiceValue, node]) => [
  173. choiceValue,
  174. <RadioItemWrapper key={choiceValue}>{node}</RadioItemWrapper>,
  175. ]);
  176. }
  177. getUpdatedData(): RequestDataFragment {
  178. let defaultRules: boolean;
  179. let shouldCreateCustomRule: boolean;
  180. const alertSetting: Actions = parseInt(this.state.alertSetting, 10);
  181. switch (alertSetting) {
  182. case Actions.ALERT_ON_EVERY_ISSUE:
  183. defaultRules = true;
  184. shouldCreateCustomRule = false;
  185. break;
  186. case Actions.CREATE_ALERT_LATER:
  187. defaultRules = false;
  188. shouldCreateCustomRule = false;
  189. break;
  190. case Actions.CUSTOMIZED_ALERTS:
  191. defaultRules = false;
  192. shouldCreateCustomRule = true;
  193. break;
  194. default:
  195. throw new RangeError('Supplied alert creation action is not handled');
  196. }
  197. return {
  198. defaultRules,
  199. shouldCreateCustomRule,
  200. name: 'Send a notification for new issues',
  201. conditions:
  202. this.state.interval.length > 0 && this.state.threshold.length > 0
  203. ? [
  204. getConditionFrom(
  205. this.state.interval,
  206. this.state.metric,
  207. this.state.threshold
  208. ),
  209. ]
  210. : undefined,
  211. actions: [
  212. {
  213. ...ISSUE_ALERT_DEFAULT_ACTION,
  214. ...(this.props.organization.features.includes('issue-alert-fallback-targeting')
  215. ? {fallthroughType: 'ActiveMembers'}
  216. : {}),
  217. },
  218. ],
  219. actionMatch: 'all',
  220. frequency: 5,
  221. };
  222. }
  223. setStateAndUpdateParents<K extends keyof State>(
  224. state:
  225. | ((
  226. prevState: Readonly<State>,
  227. props: Readonly<Props>
  228. ) => Pick<State, K> | State | null)
  229. | Pick<State, K>
  230. | State
  231. | null
  232. ): void {
  233. this.setState(state, () => {
  234. this.props.onChange(this.getUpdatedData());
  235. });
  236. }
  237. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  238. return [['conditions', `/projects/${this.props.organization.slug}/rule-conditions/`]];
  239. }
  240. onLoadAllEndpointsSuccess(): void {
  241. const conditions = this.state.conditions?.filter?.(object =>
  242. Object.values(METRIC_CONDITION_MAP).includes(object?.id)
  243. );
  244. if (!conditions || conditions.length === 0) {
  245. this.setStateAndUpdateParents({
  246. conditions: undefined,
  247. });
  248. return;
  249. }
  250. const {intervalChoices, interval} = unpackConditions(conditions);
  251. if (!intervalChoices || !interval) {
  252. Sentry.withScope(scope => {
  253. scope.setExtra('props', this.props);
  254. scope.setExtra('state', this.state);
  255. Sentry.captureException(
  256. new Error('Interval choices or sent from API endpoint is inconsistent or empty')
  257. );
  258. });
  259. this.setStateAndUpdateParents({
  260. conditions: undefined,
  261. });
  262. return;
  263. }
  264. this.setStateAndUpdateParents({
  265. conditions,
  266. intervalChoices,
  267. interval,
  268. });
  269. }
  270. renderBody(): React.ReactElement {
  271. const issueAlertOptionsChoices = this.getIssueAlertsChoices(
  272. this.state.conditions?.length > 0
  273. );
  274. return (
  275. <Fragment>
  276. <PageHeadingWithTopMargins withMargins>
  277. {t('2. Set your alert frequency')}
  278. </PageHeadingWithTopMargins>
  279. <Content>
  280. <RadioGroupWithPadding
  281. choices={issueAlertOptionsChoices}
  282. label={t('Options for creating an alert')}
  283. onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
  284. value={this.state.alertSetting}
  285. />
  286. </Content>
  287. </Fragment>
  288. );
  289. }
  290. }
  291. export default withOrganization(IssueAlertOptions);
  292. const Content = styled('div')`
  293. padding-top: ${space(2)};
  294. padding-bottom: ${space(4)};
  295. `;
  296. const CustomizeAlertsGrid = styled('div')`
  297. display: grid;
  298. grid-template-columns: repeat(5, max-content);
  299. gap: ${space(1)};
  300. align-items: center;
  301. `;
  302. const InlineInput = styled(Input)`
  303. width: 80px;
  304. `;
  305. const InlineSelectControl = styled(SelectControl)`
  306. width: 160px;
  307. `;
  308. const RadioGroupWithPadding = styled(RadioGroup)`
  309. margin-bottom: ${space(2)};
  310. `;
  311. const PageHeadingWithTopMargins = styled(Layout.Title)`
  312. margin-top: 65px;
  313. margin-bottom: 0;
  314. padding-bottom: ${space(3)};
  315. border-bottom: 1px solid rgba(0, 0, 0, 0.1);
  316. `;
  317. const RadioItemWrapper = styled('div')`
  318. min-height: 35px;
  319. display: flex;
  320. flex-direction: column;
  321. justify-content: center;
  322. `;