import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import isEqual from 'lodash/isEqual'; import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent'; import RadioGroup from 'sentry/components/forms/controls/radioGroup'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import Input from 'sentry/components/input'; import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {IssueAlertRuleAction} from 'sentry/types/alerts'; import {IssueAlertActionType, IssueAlertConditionType} from 'sentry/types/alerts'; import type {Organization} from 'sentry/types/organization'; import withOrganization from 'sentry/utils/withOrganization'; export enum MetricValues { ERRORS = 0, USERS = 1, } export enum RuleAction { DEFAULT_ALERT = 0, CUSTOMIZED_ALERTS = 1, CREATE_ALERT_LATER = 2, } const ISSUE_ALERT_DEFAULT_ACTION: Omit< IssueAlertRuleAction, 'label' | 'name' | 'prompt' > = { id: IssueAlertActionType.NOTIFY_EMAIL, targetType: 'IssueOwners', fallthroughType: 'ActiveMembers', }; const METRIC_CONDITION_MAP = { [MetricValues.ERRORS]: IssueAlertConditionType.EVENT_FREQUENCY, [MetricValues.USERS]: IssueAlertConditionType.EVENT_UNIQUE_USER_FREQUENCY, } as const; type StateUpdater = (updatedData: RequestDataFragment) => void; type Props = DeprecatedAsyncComponent['props'] & { onChange: StateUpdater; organization: Organization; alertSetting?: string; interval?: string; metric?: MetricValues; platformLanguage?: SupportedLanguages; threshold?: string; }; type State = DeprecatedAsyncComponent['state'] & { alertSetting: string; // TODO(ts): When we have alert conditional types, convert this conditions: any; interval: string; intervalChoices: [string, string][] | undefined; metric: MetricValues; threshold: string; }; type RequestDataFragment = { actionMatch: string; actions: Omit[]; conditions: {id: string; interval: string; value: string}[] | undefined; defaultRules: boolean; frequency: number; name: string; shouldCreateCustomRule: boolean; }; function getConditionFrom( interval: string, metricValue: MetricValues, threshold: string ): {id: string; interval: string; value: string} { let condition: string; switch (metricValue) { case MetricValues.ERRORS: condition = IssueAlertConditionType.EVENT_FREQUENCY; break; case MetricValues.USERS: condition = IssueAlertConditionType.EVENT_UNIQUE_USER_FREQUENCY; break; default: throw new RangeError('Supplied metric value is not handled'); } return { interval, id: condition, value: threshold, }; } function unpackConditions(conditions: any[]) { const equalityReducer = (acc, curr) => { if (!acc || !curr || !isEqual(acc, curr)) { return null; } return acc; }; const intervalChoices = conditions .map(condition => condition.formFields?.interval?.choices) .reduce(equalityReducer); return {intervalChoices, interval: intervalChoices?.[0]?.[0]}; } class IssueAlertOptions extends DeprecatedAsyncComponent { getDefaultState(): State { return { ...super.getDefaultState(), conditions: [], intervalChoices: [], alertSetting: this.props.alertSetting ?? RuleAction.DEFAULT_ALERT.toString(), metric: this.props.metric ?? MetricValues.ERRORS, interval: this.props.interval ?? '', threshold: this.props.threshold ?? '10', }; } getAvailableMetricOptions() { return [ {value: MetricValues.ERRORS, label: t('occurrences of')}, {value: MetricValues.USERS, label: t('users affected by')}, ].filter(({value}) => { return this.state.conditions?.some?.( object => object?.id === METRIC_CONDITION_MAP[value] ); }); } getIssueAlertsChoices( hasProperlyLoadedConditions: boolean ): [string, string | React.ReactElement][] { const customizedAlertOption: [string, React.ReactNode] = [ RuleAction.CUSTOMIZED_ALERTS.toString(), { // XXX(epurkhiser): The `e.preventDefault` here is needed to stop // propagation of the click up to the label, causing it to focus // the radio input and lose focus on the select. e.preventDefault(); const alertSetting = RuleAction.CUSTOMIZED_ALERTS.toString(); this.setStateAndUpdateParents({alertSetting}); }} > {t('When there are more than')} this.setStateAndUpdateParents({threshold: threshold.target.value}) } data-test-id="range-input" /> this.setStateAndUpdateParents({metric: metric.value})} /> {t('a unique error in')} ({ value, label, }))} onChange={interval => this.setStateAndUpdateParents({interval: interval.value})} /> , ]; const default_label = this.shouldUseNewDefaultSetting() ? t('Alert me on high priority issues') : t('Alert me on every new issue'); const options: [string, React.ReactNode][] = [ [RuleAction.DEFAULT_ALERT.toString(), default_label], ...(hasProperlyLoadedConditions ? [customizedAlertOption] : []), [RuleAction.CREATE_ALERT_LATER.toString(), t("I'll create my own alerts later")], ]; return options.map(([choiceValue, node]) => [ choiceValue, {node}, ]); } shouldUseNewDefaultSetting(): boolean { return ( this.props.organization.features.includes('default-high-priority-alerts') && (this.props.platformLanguage === SupportedLanguages.PYTHON || this.props.platformLanguage === SupportedLanguages.JAVASCRIPT) ); } getUpdatedData(): RequestDataFragment { let defaultRules: boolean; let shouldCreateCustomRule: boolean; const alertSetting: RuleAction = parseInt(this.state.alertSetting, 10); switch (alertSetting) { case RuleAction.DEFAULT_ALERT: defaultRules = true; shouldCreateCustomRule = false; break; case RuleAction.CREATE_ALERT_LATER: defaultRules = false; shouldCreateCustomRule = false; break; case RuleAction.CUSTOMIZED_ALERTS: defaultRules = false; shouldCreateCustomRule = true; break; default: throw new RangeError('Supplied alert creation action is not handled'); } return { defaultRules, shouldCreateCustomRule, name: 'Send a notification for new issues', conditions: this.state.interval.length > 0 && this.state.threshold.length > 0 ? [ getConditionFrom( this.state.interval, this.state.metric, this.state.threshold ), ] : undefined, actions: [ISSUE_ALERT_DEFAULT_ACTION], actionMatch: 'all', frequency: 5, }; } setStateAndUpdateParents( state: | (( prevState: Readonly, props: Readonly ) => Pick | State | null) | Pick | State | null ): void { this.setState(state, () => { this.props.onChange(this.getUpdatedData()); }); } getEndpoints(): ReturnType { return [['conditions', `/projects/${this.props.organization.slug}/rule-conditions/`]]; } onLoadAllEndpointsSuccess(): void { const conditions = this.state.conditions?.filter?.(object => Object.values(METRIC_CONDITION_MAP).includes(object?.id) ); if (!conditions || conditions.length === 0) { this.setStateAndUpdateParents({ conditions: undefined, }); return; } const {intervalChoices, interval} = unpackConditions(conditions); if (!intervalChoices || !interval) { Sentry.withScope(scope => { scope.setExtra('props', this.props); scope.setExtra('state', this.state); Sentry.captureException( new Error('Interval choices or sent from API endpoint is inconsistent or empty') ); }); this.setStateAndUpdateParents({ conditions: undefined, }); return; } const newInterval = this.props.interval && intervalChoices.some(intervalChoice => intervalChoice[0] === this.props.interval) ? this.props.interval : interval; this.setStateAndUpdateParents({ conditions, intervalChoices, interval: newInterval, }); } renderBody(): React.ReactElement { const issueAlertOptionsChoices = this.getIssueAlertsChoices( this.state.conditions?.length > 0 ); return ( this.setStateAndUpdateParents({alertSetting})} value={this.state.alertSetting} /> ); } } export default withOrganization(IssueAlertOptions); const Content = styled('div')` padding-top: ${space(2)}; padding-bottom: ${space(4)}; `; const CustomizeAlert = styled('div')` display: flex; gap: ${space(1)}; flex-wrap: wrap; align-items: center; `; const InlineInput = styled(Input)` width: 80px; `; const InlineSelectControl = styled(SelectControl)` width: 160px; `; const RadioGroupWithPadding = styled(RadioGroup)` margin-bottom: ${space(2)}; `; const RadioItemWrapper = styled('div')` min-height: 35px; display: flex; flex-direction: column; justify-content: center; `;