issueAlertOptions.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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 {IssueAlertRuleAction} from 'sentry/types/alerts';
  12. import {IssueAlertActionType, IssueAlertConditionType} from 'sentry/types/alerts';
  13. import type {Organization} from 'sentry/types/organization';
  14. import withOrganization from 'sentry/utils/withOrganization';
  15. export enum MetricValues {
  16. ERRORS = 0,
  17. USERS = 1,
  18. }
  19. export enum RuleAction {
  20. DEFAULT_ALERT = 0,
  21. CUSTOMIZED_ALERTS = 1,
  22. CREATE_ALERT_LATER = 2,
  23. }
  24. const ISSUE_ALERT_DEFAULT_ACTION: Omit<
  25. IssueAlertRuleAction,
  26. 'label' | 'name' | 'prompt'
  27. > = {
  28. id: IssueAlertActionType.NOTIFY_EMAIL,
  29. targetType: 'IssueOwners',
  30. fallthroughType: 'ActiveMembers',
  31. };
  32. const METRIC_CONDITION_MAP = {
  33. [MetricValues.ERRORS]: IssueAlertConditionType.EVENT_FREQUENCY,
  34. [MetricValues.USERS]: IssueAlertConditionType.EVENT_UNIQUE_USER_FREQUENCY,
  35. } as const;
  36. type StateUpdater = (updatedData: RequestDataFragment) => void;
  37. type Props = DeprecatedAsyncComponent['props'] & {
  38. onChange: StateUpdater;
  39. organization: Organization;
  40. alertSetting?: string;
  41. interval?: string;
  42. metric?: MetricValues;
  43. platformLanguage?: SupportedLanguages;
  44. threshold?: string;
  45. };
  46. type State = DeprecatedAsyncComponent['state'] & {
  47. alertSetting: string;
  48. // TODO(ts): When we have alert conditional types, convert this
  49. conditions: any;
  50. interval: string;
  51. intervalChoices: [string, string][] | undefined;
  52. metric: MetricValues;
  53. threshold: string;
  54. };
  55. type RequestDataFragment = {
  56. actionMatch: string;
  57. actions: Omit<IssueAlertRuleAction, 'label' | 'name' | 'prompt'>[];
  58. conditions: {id: string; interval: string; value: string}[] | undefined;
  59. defaultRules: boolean;
  60. frequency: number;
  61. name: string;
  62. shouldCreateCustomRule: boolean;
  63. };
  64. function getConditionFrom(
  65. interval: string,
  66. metricValue: MetricValues,
  67. threshold: string
  68. ): {id: string; interval: string; value: string} {
  69. let condition: string;
  70. switch (metricValue) {
  71. case MetricValues.ERRORS:
  72. condition = IssueAlertConditionType.EVENT_FREQUENCY;
  73. break;
  74. case MetricValues.USERS:
  75. condition = IssueAlertConditionType.EVENT_UNIQUE_USER_FREQUENCY;
  76. break;
  77. default:
  78. throw new RangeError('Supplied metric value is not handled');
  79. }
  80. return {
  81. interval,
  82. id: condition,
  83. value: threshold,
  84. };
  85. }
  86. function unpackConditions(conditions: any[]) {
  87. const equalityReducer = (acc, curr) => {
  88. if (!acc || !curr || !isEqual(acc, curr)) {
  89. return null;
  90. }
  91. return acc;
  92. };
  93. const intervalChoices = conditions
  94. .map(condition => condition.formFields?.interval?.choices)
  95. .reduce(equalityReducer);
  96. return {intervalChoices, interval: intervalChoices?.[0]?.[0]};
  97. }
  98. class IssueAlertOptions extends DeprecatedAsyncComponent<Props, State> {
  99. getDefaultState(): State {
  100. return {
  101. ...super.getDefaultState(),
  102. conditions: [],
  103. intervalChoices: [],
  104. alertSetting: this.props.alertSetting ?? RuleAction.DEFAULT_ALERT.toString(),
  105. metric: this.props.metric ?? MetricValues.ERRORS,
  106. interval: this.props.interval ?? '',
  107. threshold: this.props.threshold ?? '10',
  108. };
  109. }
  110. getAvailableMetricOptions() {
  111. return [
  112. {value: MetricValues.ERRORS, label: t('occurrences of')},
  113. {value: MetricValues.USERS, label: t('users affected by')},
  114. ].filter(({value}) => {
  115. return this.state.conditions?.some?.(
  116. object => object?.id === METRIC_CONDITION_MAP[value]
  117. );
  118. });
  119. }
  120. getIssueAlertsChoices(
  121. hasProperlyLoadedConditions: boolean
  122. ): [string, string | React.ReactElement][] {
  123. const customizedAlertOption: [string, React.ReactNode] = [
  124. RuleAction.CUSTOMIZED_ALERTS.toString(),
  125. <CustomizeAlert
  126. key={RuleAction.CUSTOMIZED_ALERTS}
  127. onClick={e => {
  128. // XXX(epurkhiser): The `e.preventDefault` here is needed to stop
  129. // propagation of the click up to the label, causing it to focus
  130. // the radio input and lose focus on the select.
  131. e.preventDefault();
  132. const alertSetting = RuleAction.CUSTOMIZED_ALERTS.toString();
  133. this.setStateAndUpdateParents({alertSetting});
  134. }}
  135. >
  136. {t('When there are more than')}
  137. <InlineInput
  138. type="number"
  139. min="0"
  140. name=""
  141. placeholder="10"
  142. value={this.state.threshold}
  143. onChange={threshold =>
  144. this.setStateAndUpdateParents({threshold: threshold.target.value})
  145. }
  146. data-test-id="range-input"
  147. />
  148. <InlineSelectControl
  149. value={this.state.metric}
  150. options={this.getAvailableMetricOptions()}
  151. onChange={metric => this.setStateAndUpdateParents({metric: metric.value})}
  152. />
  153. {t('a unique error in')}
  154. <InlineSelectControl
  155. value={this.state.interval}
  156. options={this.state.intervalChoices?.map(([value, label]) => ({
  157. value,
  158. label,
  159. }))}
  160. onChange={interval => this.setStateAndUpdateParents({interval: interval.value})}
  161. />
  162. </CustomizeAlert>,
  163. ];
  164. const default_label = this.shouldUseNewDefaultSetting()
  165. ? t('Alert me on high priority issues')
  166. : t('Alert me on every new issue');
  167. const options: [string, React.ReactNode][] = [
  168. [RuleAction.DEFAULT_ALERT.toString(), default_label],
  169. ...(hasProperlyLoadedConditions ? [customizedAlertOption] : []),
  170. [RuleAction.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. shouldUseNewDefaultSetting(): boolean {
  178. return (
  179. this.props.organization.features.includes('default-high-priority-alerts') &&
  180. (this.props.platformLanguage === SupportedLanguages.PYTHON ||
  181. this.props.platformLanguage === SupportedLanguages.JAVASCRIPT)
  182. );
  183. }
  184. getUpdatedData(): RequestDataFragment {
  185. let defaultRules: boolean;
  186. let shouldCreateCustomRule: boolean;
  187. const alertSetting: RuleAction = parseInt(this.state.alertSetting, 10);
  188. switch (alertSetting) {
  189. case RuleAction.DEFAULT_ALERT:
  190. defaultRules = true;
  191. shouldCreateCustomRule = false;
  192. break;
  193. case RuleAction.CREATE_ALERT_LATER:
  194. defaultRules = false;
  195. shouldCreateCustomRule = false;
  196. break;
  197. case RuleAction.CUSTOMIZED_ALERTS:
  198. defaultRules = false;
  199. shouldCreateCustomRule = true;
  200. break;
  201. default:
  202. throw new RangeError('Supplied alert creation action is not handled');
  203. }
  204. return {
  205. defaultRules,
  206. shouldCreateCustomRule,
  207. name: 'Send a notification for new issues',
  208. conditions:
  209. this.state.interval.length > 0 && this.state.threshold.length > 0
  210. ? [
  211. getConditionFrom(
  212. this.state.interval,
  213. this.state.metric,
  214. this.state.threshold
  215. ),
  216. ]
  217. : undefined,
  218. actions: [ISSUE_ALERT_DEFAULT_ACTION],
  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<DeprecatedAsyncComponent['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. const newInterval =
  265. this.props.interval &&
  266. intervalChoices.some(intervalChoice => intervalChoice[0] === this.props.interval)
  267. ? this.props.interval
  268. : interval;
  269. this.setStateAndUpdateParents({
  270. conditions,
  271. intervalChoices,
  272. interval: newInterval,
  273. });
  274. }
  275. renderBody(): React.ReactElement {
  276. const issueAlertOptionsChoices = this.getIssueAlertsChoices(
  277. this.state.conditions?.length > 0
  278. );
  279. return (
  280. <Content>
  281. <RadioGroupWithPadding
  282. choices={issueAlertOptionsChoices}
  283. label={t('Options for creating an alert')}
  284. onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
  285. value={this.state.alertSetting}
  286. />
  287. </Content>
  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 CustomizeAlert = styled('div')`
  297. display: flex;
  298. gap: ${space(1)};
  299. flex-wrap: wrap;
  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 RadioItemWrapper = styled('div')`
  312. min-height: 35px;
  313. display: flex;
  314. flex-direction: column;
  315. justify-content: center;
  316. `;