issueAlertOptions.tsx 10 KB

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