issueAlertOptions.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import * as Sentry from '@sentry/react';
  5. import isEqual from 'lodash/isEqual';
  6. import AsyncComponent from 'sentry/components/asyncComponent';
  7. import RadioGroup from 'sentry/components/forms/controls/radioGroup';
  8. import MultipleCheckboxField from 'sentry/components/forms/MultipleCheckboxField';
  9. import SelectControl from 'sentry/components/forms/selectControl';
  10. import Input from 'sentry/components/input';
  11. import PageHeading from 'sentry/components/pageHeading';
  12. import {t} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {Organization} from 'sentry/types';
  15. import withOrganization from 'sentry/utils/withOrganization';
  16. import {PRESET_AGGREGATES} from '../alerts/rules/metric/presets';
  17. enum MetricValues {
  18. ERRORS,
  19. USERS,
  20. }
  21. enum Actions {
  22. ALERT_ON_EVERY_ISSUE,
  23. CUSTOMIZED_ALERTS,
  24. CREATE_ALERT_LATER,
  25. }
  26. const UNIQUE_USER_FREQUENCY_CONDITION =
  27. 'sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyCondition';
  28. const EVENT_FREQUENCY_CONDITION =
  29. 'sentry.rules.conditions.event_frequency.EventFrequencyCondition';
  30. const NOTIFY_EVENT_ACTION = 'sentry.rules.actions.notify_event.NotifyEventAction';
  31. export const EVENT_FREQUENCY_PERCENT_CONDITION =
  32. 'sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition';
  33. const METRIC_CONDITION_MAP = {
  34. [MetricValues.ERRORS]: EVENT_FREQUENCY_CONDITION,
  35. [MetricValues.USERS]: UNIQUE_USER_FREQUENCY_CONDITION,
  36. } as const;
  37. const DEFAULT_PLACEHOLDER_VALUE = '10';
  38. type StateUpdater = (updatedData: RequestDataFragment) => void;
  39. type Props = AsyncComponent['props'] & {
  40. onChange: StateUpdater;
  41. organization: Organization;
  42. };
  43. type State = AsyncComponent['state'] & {
  44. alertSetting: string;
  45. // TODO(ts): When we have alert conditional types, convert this
  46. conditions: any;
  47. interval: string;
  48. intervalChoices: [string, string][] | undefined;
  49. metric: MetricValues;
  50. metricAlertPresets: Set<string>;
  51. threshold: string;
  52. };
  53. type RequestDataFragment = {
  54. actionMatch: string;
  55. actions: {id: string}[];
  56. conditions: {id: string; interval: string; value: string}[] | undefined;
  57. defaultRules: boolean;
  58. frequency: number;
  59. metricAlertPresets: string[];
  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 = EVENT_FREQUENCY_CONDITION;
  72. break;
  73. case MetricValues.USERS:
  74. condition = UNIQUE_USER_FREQUENCY_CONDITION;
  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 AsyncComponent<Props, State> {
  98. getDefaultState(): State {
  99. return {
  100. ...super.getDefaultState(),
  101. conditions: [],
  102. intervalChoices: [],
  103. alertSetting: Actions.CREATE_ALERT_LATER.toString(),
  104. metric: MetricValues.ERRORS,
  105. interval: '',
  106. threshold: '',
  107. metricAlertPresets: new Set(),
  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 options: [string, React.ReactNode][] = [
  124. [Actions.CREATE_ALERT_LATER.toString(), t("I'll create my own alerts later")],
  125. [Actions.ALERT_ON_EVERY_ISSUE.toString(), t('Alert me on every new issue')],
  126. ];
  127. if (hasProperlyLoadedConditions) {
  128. options.push([
  129. Actions.CUSTOMIZED_ALERTS.toString(),
  130. <CustomizeAlertsGrid
  131. key={Actions.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 = Actions.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={DEFAULT_PLACEHOLDER_VALUE}
  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 => this.setStateAndUpdateParents({metric: metric.value})}
  157. data-test-id="metric-select-control"
  158. />
  159. {t('a unique error in')}
  160. <InlineSelectControl
  161. value={this.state.interval}
  162. options={this.state.intervalChoices?.map(([value, label]) => ({
  163. value,
  164. label,
  165. }))}
  166. onChange={interval =>
  167. this.setStateAndUpdateParents({interval: interval.value})
  168. }
  169. data-test-id="interval-select-control"
  170. />
  171. </CustomizeAlertsGrid>,
  172. ]);
  173. }
  174. return options.map(([choiceValue, node]) => [
  175. choiceValue,
  176. <RadioItemWrapper key={choiceValue}>{node}</RadioItemWrapper>,
  177. ]);
  178. }
  179. getUpdatedData(): RequestDataFragment {
  180. let defaultRules: boolean;
  181. let shouldCreateCustomRule: boolean;
  182. const alertSetting: Actions = parseInt(this.state.alertSetting, 10);
  183. switch (alertSetting) {
  184. case Actions.ALERT_ON_EVERY_ISSUE:
  185. defaultRules = true;
  186. shouldCreateCustomRule = false;
  187. break;
  188. case Actions.CREATE_ALERT_LATER:
  189. defaultRules = false;
  190. shouldCreateCustomRule = false;
  191. break;
  192. case Actions.CUSTOMIZED_ALERTS:
  193. defaultRules = false;
  194. shouldCreateCustomRule = true;
  195. break;
  196. default:
  197. throw new RangeError('Supplied alert creation action is not handled');
  198. }
  199. return {
  200. defaultRules,
  201. shouldCreateCustomRule,
  202. name: 'Send a notification for new issues',
  203. conditions:
  204. this.state.interval.length > 0 && this.state.threshold.length > 0
  205. ? [
  206. getConditionFrom(
  207. this.state.interval,
  208. this.state.metric,
  209. this.state.threshold
  210. ),
  211. ]
  212. : undefined,
  213. actions: [{id: NOTIFY_EVENT_ACTION}],
  214. actionMatch: 'all',
  215. frequency: 5,
  216. metricAlertPresets: Array.from(this.state.metricAlertPresets),
  217. };
  218. }
  219. setStateAndUpdateParents<K extends keyof State>(
  220. state:
  221. | ((
  222. prevState: Readonly<State>,
  223. props: Readonly<Props>
  224. ) => Pick<State, K> | State | null)
  225. | Pick<State, K>
  226. | State
  227. | null,
  228. callback?: () => void
  229. ): void {
  230. this.setState(state, () => {
  231. callback?.();
  232. this.props.onChange(this.getUpdatedData());
  233. });
  234. }
  235. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  236. return [['conditions', `/projects/${this.props.organization.slug}/rule-conditions/`]];
  237. }
  238. onLoadAllEndpointsSuccess(): void {
  239. const conditions = this.state.conditions?.filter?.(object =>
  240. Object.values(METRIC_CONDITION_MAP).includes(object?.id)
  241. );
  242. if (!conditions || conditions.length === 0) {
  243. this.setStateAndUpdateParents({
  244. conditions: undefined,
  245. });
  246. return;
  247. }
  248. const {intervalChoices, interval} = unpackConditions(conditions);
  249. if (!intervalChoices || !interval) {
  250. Sentry.withScope(scope => {
  251. scope.setExtra('props', this.props);
  252. scope.setExtra('state', this.state);
  253. Sentry.captureException(
  254. new Error('Interval choices or sent from API endpoint is inconsistent or empty')
  255. );
  256. });
  257. this.setStateAndUpdateParents({
  258. conditions: undefined,
  259. });
  260. return;
  261. }
  262. this.setStateAndUpdateParents({
  263. conditions,
  264. intervalChoices,
  265. interval,
  266. });
  267. }
  268. renderBody(): React.ReactElement {
  269. const issueAlertOptionsChoices = this.getIssueAlertsChoices(
  270. this.state.conditions?.length > 0
  271. );
  272. const showMetricAlertSelections =
  273. !!this.props.organization.experiments.MetricAlertOnProjectCreationExperiment;
  274. return (
  275. <Fragment>
  276. <PageHeadingWithTopMargins withMargins>
  277. {t('Set your default alert settings')}
  278. </PageHeadingWithTopMargins>
  279. <Content>
  280. {showMetricAlertSelections && <Subheading>{t('Issue Alerts')}</Subheading>}
  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. {showMetricAlertSelections && (
  288. <Fragment>
  289. <Subheading>{t('Performance Alerts')}</Subheading>
  290. <MultipleCheckboxField
  291. size="24px"
  292. choices={PRESET_AGGREGATES.map(agg => ({
  293. title: agg.description,
  294. value: agg.id,
  295. checked: this.state.metricAlertPresets.has(agg.id),
  296. }))}
  297. css={CheckboxFieldStyles}
  298. onClick={selectedItem => {
  299. const next = new Set(this.state.metricAlertPresets);
  300. if (next.has(selectedItem)) {
  301. next.delete(selectedItem);
  302. } else {
  303. next.add(selectedItem);
  304. }
  305. this.setStateAndUpdateParents({
  306. metricAlertPresets: next,
  307. });
  308. }}
  309. />
  310. </Fragment>
  311. )}
  312. </Content>
  313. </Fragment>
  314. );
  315. }
  316. }
  317. export default withOrganization(IssueAlertOptions);
  318. const CheckboxFieldStyles = css`
  319. margin-top: ${space(1)};
  320. `;
  321. const Content = styled('div')`
  322. padding-top: ${space(2)};
  323. padding-bottom: ${space(4)};
  324. `;
  325. const CustomizeAlertsGrid = styled('div')`
  326. display: grid;
  327. grid-template-columns: repeat(5, max-content);
  328. gap: ${space(1)};
  329. align-items: center;
  330. `;
  331. const InlineInput = styled(Input)`
  332. width: 80px;
  333. `;
  334. const InlineSelectControl = styled(SelectControl)`
  335. width: 160px;
  336. `;
  337. const RadioGroupWithPadding = styled(RadioGroup)`
  338. margin-bottom: ${space(2)};
  339. `;
  340. const PageHeadingWithTopMargins = styled(PageHeading)`
  341. margin-top: 65px;
  342. margin-bottom: 0;
  343. padding-bottom: ${space(3)};
  344. border-bottom: 1px solid rgba(0, 0, 0, 0.1);
  345. `;
  346. const RadioItemWrapper = styled('div')`
  347. min-height: 35px;
  348. display: flex;
  349. flex-direction: column;
  350. justify-content: center;
  351. `;
  352. const Subheading = styled('b')`
  353. display: block;
  354. `;