issueAlertOptions.tsx 10 KB

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