123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- import {Fragment} from 'react';
- import styled from '@emotion/styled';
- import * as Sentry from '@sentry/react';
- import isEqual from 'lodash/isEqual';
- import AsyncComponent from 'sentry/components/asyncComponent';
- import RadioGroup from 'sentry/components/forms/controls/radioGroup';
- import SelectControl from 'sentry/components/forms/controls/selectControl';
- import Input from 'sentry/components/input';
- import * as Layout from 'sentry/components/layouts/thirds';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {Organization} from 'sentry/types';
- import {IssueAlertRuleAction} from 'sentry/types/alerts';
- import withOrganization from 'sentry/utils/withOrganization';
- export enum MetricValues {
- ERRORS,
- USERS,
- }
- export enum RuleAction {
- ALERT_ON_EVERY_ISSUE,
- CUSTOMIZED_ALERTS,
- CREATE_ALERT_LATER,
- }
- const UNIQUE_USER_FREQUENCY_CONDITION =
- 'sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyCondition';
- const EVENT_FREQUENCY_CONDITION =
- 'sentry.rules.conditions.event_frequency.EventFrequencyCondition';
- export const EVENT_FREQUENCY_PERCENT_CONDITION =
- 'sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition';
- export const REAPPEARED_EVENT_CONDITION =
- 'sentry.rules.conditions.reappeared_event.ReappearedEventCondition';
- const ISSUE_ALERT_DEFAULT_ACTION: Omit<
- IssueAlertRuleAction,
- 'label' | 'name' | 'prompt'
- > = {
- id: 'sentry.mail.actions.NotifyEmailAction',
- targetType: 'IssueOwners',
- };
- const METRIC_CONDITION_MAP = {
- [MetricValues.ERRORS]: EVENT_FREQUENCY_CONDITION,
- [MetricValues.USERS]: UNIQUE_USER_FREQUENCY_CONDITION,
- } as const;
- const DEFAULT_PLACEHOLDER_VALUE = '10';
- type StateUpdater = (updatedData: RequestDataFragment) => void;
- type Props = AsyncComponent['props'] & {
- onChange: StateUpdater;
- organization: Organization;
- alertSetting?: string;
- interval?: string;
- metric?: MetricValues;
- threshold?: string;
- };
- type State = AsyncComponent['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<IssueAlertRuleAction, 'label' | 'name' | 'prompt'>[];
- 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 = EVENT_FREQUENCY_CONDITION;
- break;
- case MetricValues.USERS:
- condition = UNIQUE_USER_FREQUENCY_CONDITION;
- 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 AsyncComponent<Props, State> {
- getDefaultState(): State {
- return {
- ...super.getDefaultState(),
- conditions: [],
- intervalChoices: [],
- alertSetting: this.props.alertSetting ?? RuleAction.ALERT_ON_EVERY_ISSUE.toString(),
- metric: this.props.metric ?? MetricValues.ERRORS,
- interval: this.props.interval ?? '',
- threshold: this.props.threshold ?? '',
- };
- }
- 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(),
- <CustomizeAlertsGrid
- key={RuleAction.CUSTOMIZED_ALERTS}
- onClick={e => {
- // 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')}
- <InlineInput
- type="number"
- min="0"
- name=""
- placeholder={DEFAULT_PLACEHOLDER_VALUE}
- value={this.state.threshold}
- onChange={threshold =>
- this.setStateAndUpdateParents({threshold: threshold.target.value})
- }
- data-test-id="range-input"
- />
- <InlineSelectControl
- value={this.state.metric}
- options={this.getAvailableMetricOptions()}
- onChange={metric => this.setStateAndUpdateParents({metric: metric.value})}
- />
- {t('a unique error in')}
- <InlineSelectControl
- value={this.state.interval}
- options={this.state.intervalChoices?.map(([value, label]) => ({
- value,
- label,
- }))}
- onChange={interval => this.setStateAndUpdateParents({interval: interval.value})}
- />
- </CustomizeAlertsGrid>,
- ];
- const options: [string, React.ReactNode][] = [
- [RuleAction.ALERT_ON_EVERY_ISSUE.toString(), t('Alert me on every new issue')],
- ...(hasProperlyLoadedConditions ? [customizedAlertOption] : []),
- [RuleAction.CREATE_ALERT_LATER.toString(), t("I'll create my own alerts later")],
- ];
- return options.map(([choiceValue, node]) => [
- choiceValue,
- <RadioItemWrapper key={choiceValue}>{node}</RadioItemWrapper>,
- ]);
- }
- getUpdatedData(): RequestDataFragment {
- let defaultRules: boolean;
- let shouldCreateCustomRule: boolean;
- const alertSetting: RuleAction = parseInt(this.state.alertSetting, 10);
- switch (alertSetting) {
- case RuleAction.ALERT_ON_EVERY_ISSUE:
- 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,
- ...(this.props.organization.features.includes('issue-alert-fallback-targeting')
- ? {fallthroughType: 'ActiveMembers'}
- : {}),
- },
- ],
- actionMatch: 'all',
- frequency: 5,
- };
- }
- setStateAndUpdateParents<K extends keyof State>(
- state:
- | ((
- prevState: Readonly<State>,
- props: Readonly<Props>
- ) => Pick<State, K> | State | null)
- | Pick<State, K>
- | State
- | null
- ): void {
- this.setState(state, () => {
- this.props.onChange(this.getUpdatedData());
- });
- }
- getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
- 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 (
- <Fragment>
- <PageHeadingWithTopMargins withMargins>
- {t('2. Set your alert frequency')}
- </PageHeadingWithTopMargins>
- <Content>
- <RadioGroupWithPadding
- choices={issueAlertOptionsChoices}
- label={t('Options for creating an alert')}
- onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
- value={this.state.alertSetting}
- />
- </Content>
- </Fragment>
- );
- }
- }
- export default withOrganization(IssueAlertOptions);
- const Content = styled('div')`
- padding-top: ${space(2)};
- padding-bottom: ${space(4)};
- `;
- const CustomizeAlertsGrid = styled('div')`
- display: grid;
- grid-template-columns: repeat(5, max-content);
- gap: ${space(1)};
- align-items: center;
- `;
- const InlineInput = styled(Input)`
- width: 80px;
- `;
- const InlineSelectControl = styled(SelectControl)`
- width: 160px;
- `;
- const RadioGroupWithPadding = styled(RadioGroup)`
- margin-bottom: ${space(2)};
- `;
- const PageHeadingWithTopMargins = styled(Layout.Title)`
- margin-top: 65px;
- margin-bottom: 0;
- padding-bottom: ${space(3)};
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
- `;
- const RadioItemWrapper = styled('div')`
- min-height: 35px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- `;
|