issueAlertOptions.tsx 11 KB

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