issueAlertOptions.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import {ReactElement} from 'react';
  2. import * as React from 'react';
  3. import styled from '@emotion/styled';
  4. import * as Sentry from '@sentry/react';
  5. import isEqual from 'lodash/isEqual';
  6. import AsyncComponent from 'app/components/asyncComponent';
  7. import SelectControl from 'app/components/forms/selectControl';
  8. import PageHeading from 'app/components/pageHeading';
  9. import {t} from 'app/locale';
  10. import space from 'app/styles/space';
  11. import {Organization} from 'app/types';
  12. import withOrganization from 'app/utils/withOrganization';
  13. import Input from 'app/views/settings/components/forms/controls/input';
  14. import RadioGroup from 'app/views/settings/components/forms/controls/radioGroup';
  15. enum MetricValues {
  16. ERRORS,
  17. USERS,
  18. }
  19. enum Actions {
  20. ALERT_ON_EVERY_ISSUE,
  21. CUSTOMIZED_ALERTS,
  22. CREATE_ALERT_LATER,
  23. }
  24. const UNIQUE_USER_FREQUENCY_CONDITION =
  25. 'sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyCondition';
  26. const EVENT_FREQUENCY_CONDITION =
  27. 'sentry.rules.conditions.event_frequency.EventFrequencyCondition';
  28. const NOTIFY_EVENT_ACTION = 'sentry.rules.actions.notify_event.NotifyEventAction';
  29. const METRIC_CONDITION_MAP = {
  30. [MetricValues.ERRORS]: EVENT_FREQUENCY_CONDITION,
  31. [MetricValues.USERS]: UNIQUE_USER_FREQUENCY_CONDITION,
  32. } as const;
  33. const DEFAULT_PLACEHOLDER_VALUE = '10';
  34. type StateUpdater = (updatedData: RequestDataFragment) => void;
  35. type Props = AsyncComponent['props'] & {
  36. organization: Organization;
  37. onChange: StateUpdater;
  38. };
  39. type State = AsyncComponent['state'] & {
  40. // TODO(ts): When we have alert conditional types, convert this
  41. conditions: any;
  42. intervalChoices: [string, string][] | undefined;
  43. threshold: string;
  44. interval: string;
  45. alertSetting: string;
  46. metric: MetricValues;
  47. };
  48. type RequestDataFragment = {
  49. defaultRules: boolean;
  50. shouldCreateCustomRule: boolean;
  51. name: string;
  52. conditions: {interval: string; id: string; value: string}[] | undefined;
  53. actions: {id: string}[];
  54. actionMatch: string;
  55. frequency: number;
  56. };
  57. function getConditionFrom(
  58. interval: string,
  59. metricValue: MetricValues,
  60. threshold: string
  61. ): {interval: string; id: string; value: string} {
  62. let condition: string;
  63. switch (metricValue) {
  64. case MetricValues.ERRORS:
  65. condition = EVENT_FREQUENCY_CONDITION;
  66. break;
  67. case MetricValues.USERS:
  68. condition = UNIQUE_USER_FREQUENCY_CONDITION;
  69. break;
  70. default:
  71. throw new RangeError('Supplied metric value is not handled');
  72. }
  73. return {
  74. interval,
  75. id: condition,
  76. value: threshold,
  77. };
  78. }
  79. function unpackConditions(conditions: any[]) {
  80. const equalityReducer = (acc, curr) => {
  81. if (!acc || !curr || !isEqual(acc, curr)) {
  82. return null;
  83. }
  84. return acc;
  85. };
  86. const intervalChoices = conditions
  87. .map(condition => condition.formFields?.interval?.choices)
  88. .reduce(equalityReducer);
  89. return {intervalChoices, interval: intervalChoices?.[0]?.[0]};
  90. }
  91. class IssueAlertOptions extends AsyncComponent<Props, State> {
  92. getDefaultState(): State {
  93. return {
  94. ...super.getDefaultState(),
  95. conditions: [],
  96. intervalChoices: [],
  97. alertSetting: Actions.CREATE_ALERT_LATER.toString(),
  98. metric: MetricValues.ERRORS,
  99. interval: '',
  100. threshold: '',
  101. };
  102. }
  103. getAvailableMetricChoices() {
  104. return [
  105. [MetricValues.ERRORS, t('occurrences of')],
  106. [MetricValues.USERS, t('users affected by')],
  107. ].filter(valueDescriptionPair => {
  108. const [value] = valueDescriptionPair;
  109. return this.state.conditions?.some?.(
  110. object => object?.id === METRIC_CONDITION_MAP[value]
  111. );
  112. });
  113. }
  114. getIssueAlertsChoices(
  115. hasProperlyLoadedConditions: boolean
  116. ): [string, string | ReactElement][] {
  117. const options: [string, React.ReactNode][] = [
  118. [Actions.CREATE_ALERT_LATER.toString(), t("I'll create my own alerts later")],
  119. [Actions.ALERT_ON_EVERY_ISSUE.toString(), t('Alert me on every new issue')],
  120. ];
  121. if (hasProperlyLoadedConditions) {
  122. options.push([
  123. Actions.CUSTOMIZED_ALERTS.toString(),
  124. <CustomizeAlertsGrid
  125. key={Actions.CUSTOMIZED_ALERTS}
  126. onClick={e => {
  127. // XXX(epurkhiser): The `e.preventDefault` here is needed to stop
  128. // propegation 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 = Actions.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={DEFAULT_PLACEHOLDER_VALUE}
  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. choices={this.getAvailableMetricChoices()}
  150. onChange={metric => this.setStateAndUpdateParents({metric: metric.value})}
  151. data-test-id="metric-select-control"
  152. />
  153. {t('a unique error in')}
  154. <InlineSelectControl
  155. value={this.state.interval}
  156. choices={this.state.intervalChoices}
  157. onChange={interval =>
  158. this.setStateAndUpdateParents({interval: interval.value})
  159. }
  160. data-test-id="interval-select-control"
  161. />
  162. </CustomizeAlertsGrid>,
  163. ]);
  164. }
  165. return options.map(([choiceValue, node]) => [
  166. choiceValue,
  167. <RadioItemWrapper key={choiceValue}>{node}</RadioItemWrapper>,
  168. ]);
  169. }
  170. getUpdatedData(): RequestDataFragment {
  171. let defaultRules: boolean;
  172. let shouldCreateCustomRule: boolean;
  173. const alertSetting: Actions = parseInt(this.state.alertSetting, 10);
  174. switch (alertSetting) {
  175. case Actions.ALERT_ON_EVERY_ISSUE:
  176. defaultRules = true;
  177. shouldCreateCustomRule = false;
  178. break;
  179. case Actions.CREATE_ALERT_LATER:
  180. defaultRules = false;
  181. shouldCreateCustomRule = false;
  182. break;
  183. case Actions.CUSTOMIZED_ALERTS:
  184. defaultRules = false;
  185. shouldCreateCustomRule = true;
  186. break;
  187. default:
  188. throw new RangeError('Supplied alert creation action is not handled');
  189. }
  190. return {
  191. defaultRules,
  192. shouldCreateCustomRule,
  193. name: 'Send a notification for new issues',
  194. conditions:
  195. this.state.interval.length > 0 && this.state.threshold.length > 0
  196. ? [
  197. getConditionFrom(
  198. this.state.interval,
  199. this.state.metric,
  200. this.state.threshold
  201. ),
  202. ]
  203. : undefined,
  204. actions: [{id: NOTIFY_EVENT_ACTION}],
  205. actionMatch: 'all',
  206. frequency: 5,
  207. };
  208. }
  209. setStateAndUpdateParents<K extends keyof State>(
  210. state:
  211. | ((
  212. prevState: Readonly<State>,
  213. props: Readonly<Props>
  214. ) => Pick<State, K> | State | null)
  215. | Pick<State, K>
  216. | State
  217. | null,
  218. callback?: () => void
  219. ): void {
  220. this.setState(state, () => {
  221. callback?.();
  222. this.props.onChange(this.getUpdatedData());
  223. });
  224. }
  225. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  226. return [['conditions', `/projects/${this.props.organization.slug}/rule-conditions/`]];
  227. }
  228. onLoadAllEndpointsSuccess(): void {
  229. const conditions = this.state.conditions?.filter?.(object =>
  230. Object.values(METRIC_CONDITION_MAP).includes(object?.id)
  231. );
  232. if (!conditions || conditions.length === 0) {
  233. this.setStateAndUpdateParents({
  234. conditions: undefined,
  235. });
  236. return;
  237. }
  238. const {intervalChoices, interval} = unpackConditions(conditions);
  239. if (!intervalChoices || !interval) {
  240. Sentry.withScope(scope => {
  241. scope.setExtra('props', this.props);
  242. scope.setExtra('state', this.state);
  243. Sentry.captureException(
  244. new Error('Interval choices or sent from API endpoint is inconsistent or empty')
  245. );
  246. });
  247. this.setStateAndUpdateParents({
  248. conditions: undefined,
  249. });
  250. return;
  251. }
  252. this.setStateAndUpdateParents({
  253. conditions,
  254. intervalChoices,
  255. interval,
  256. });
  257. }
  258. renderBody(): React.ReactElement {
  259. const issueAlertOptionsChoices = this.getIssueAlertsChoices(
  260. this.state.conditions?.length > 0
  261. );
  262. return (
  263. <React.Fragment>
  264. <PageHeadingWithTopMargins withMargins>
  265. {t('Set your default alert settings')}
  266. </PageHeadingWithTopMargins>
  267. <RadioGroupWithPadding
  268. choices={issueAlertOptionsChoices}
  269. label={t('Options for creating an alert')}
  270. onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
  271. value={this.state.alertSetting}
  272. />
  273. </React.Fragment>
  274. );
  275. }
  276. }
  277. export default withOrganization(IssueAlertOptions);
  278. const CustomizeAlertsGrid = styled('div')`
  279. display: grid;
  280. grid-template-columns: repeat(5, max-content);
  281. grid-gap: ${space(1)};
  282. align-items: center;
  283. `;
  284. const InlineInput = styled(Input)`
  285. width: 80px;
  286. `;
  287. const InlineSelectControl = styled(SelectControl)`
  288. width: 160px;
  289. `;
  290. const RadioGroupWithPadding = styled(RadioGroup)`
  291. padding: ${space(3)} 0;
  292. margin-bottom: 50px;
  293. box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1);
  294. `;
  295. const PageHeadingWithTopMargins = styled(PageHeading)`
  296. margin-top: 65px;
  297. `;
  298. const RadioItemWrapper = styled('div')`
  299. min-height: 35px;
  300. display: flex;
  301. flex-direction: column;
  302. justify-content: center;
  303. `;