issueAlertOptions.tsx 9.6 KB

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