issueAlertOptions.tsx 11 KB

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