issueAlertOptions.tsx 10 KB

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