123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866 |
- import {ChangeEvent, Fragment, ReactNode} from 'react';
- import {browserHistory, RouteComponentProps} from 'react-router';
- import {components} from 'react-select';
- import styled from '@emotion/styled';
- import classNames from 'classnames';
- import {Location} from 'history';
- import cloneDeep from 'lodash/cloneDeep';
- import debounce from 'lodash/debounce';
- import omit from 'lodash/omit';
- import set from 'lodash/set';
- import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
- } from 'sentry/actionCreators/indicator';
- import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
- import {hasEveryAccess} from 'sentry/components/acl/access';
- import {Alert} from 'sentry/components/alert';
- import AlertLink from 'sentry/components/alertLink';
- import {Button} from 'sentry/components/button';
- import Checkbox from 'sentry/components/checkbox';
- import Confirm from 'sentry/components/confirm';
- import SelectControl from 'sentry/components/forms/controls/selectControl';
- import FieldGroup from 'sentry/components/forms/fieldGroup';
- import FieldHelp from 'sentry/components/forms/fieldGroup/fieldHelp';
- import SelectField from 'sentry/components/forms/fields/selectField';
- import Form, {FormProps} from 'sentry/components/forms/form';
- import FormField from 'sentry/components/forms/formField';
- import IdBadge from 'sentry/components/idBadge';
- import Input from 'sentry/components/input';
- import * as Layout from 'sentry/components/layouts/thirds';
- import ExternalLink from 'sentry/components/links/externalLink';
- import List from 'sentry/components/list';
- import ListItem from 'sentry/components/list/listItem';
- import LoadingMask from 'sentry/components/loadingMask';
- import {CursorHandler} from 'sentry/components/pagination';
- import Panel from 'sentry/components/panels/panel';
- import PanelBody from 'sentry/components/panels/panelBody';
- import TeamSelector from 'sentry/components/teamSelector';
- import {Tooltip} from 'sentry/components/tooltip';
- import {ALL_ENVIRONMENTS_KEY} from 'sentry/constants';
- import {IconChevron, IconNot} from 'sentry/icons';
- import {t, tct, tn} from 'sentry/locale';
- import GroupStore from 'sentry/stores/groupStore';
- import {space} from 'sentry/styles/space';
- import {
- Environment,
- IssueOwnership,
- Member,
- OnboardingTaskKey,
- Organization,
- Project,
- Team,
- } from 'sentry/types';
- import {
- IssueAlertRule,
- IssueAlertRuleAction,
- IssueAlertRuleActionTemplate,
- IssueAlertRuleConditionTemplate,
- UnsavedIssueAlertRule,
- } from 'sentry/types/alerts';
- import {metric, trackAnalytics} from 'sentry/utils/analytics';
- import {getDisplayName} from 'sentry/utils/environment';
- import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
- import recreateRoute from 'sentry/utils/recreateRoute';
- import routeTitleGen from 'sentry/utils/routeTitle';
- import {normalizeUrl} from 'sentry/utils/withDomainRequired';
- import withOrganization from 'sentry/utils/withOrganization';
- import withProjects from 'sentry/utils/withProjects';
- import PreviewTable from 'sentry/views/alerts/rules/issue/previewTable';
- import {
- CHANGE_ALERT_CONDITION_IDS,
- CHANGE_ALERT_PLACEHOLDERS_LABELS,
- } from 'sentry/views/alerts/utils/constants';
- import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
- import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
- import {getProjectOptions} from '../utils';
- import RuleNodeList from './ruleNodeList';
- import SetupAlertIntegrationButton from './setupAlertIntegrationButton';
- const FREQUENCY_OPTIONS = [
- {value: '5', label: t('5 minutes')},
- {value: '10', label: t('10 minutes')},
- {value: '30', label: t('30 minutes')},
- {value: '60', label: t('60 minutes')},
- {value: '180', label: t('3 hours')},
- {value: '720', label: t('12 hours')},
- {value: '1440', label: t('24 hours')},
- {value: '10080', label: t('1 week')},
- {value: '43200', label: t('30 days')},
- ];
- const ACTION_MATCH_OPTIONS = [
- {value: 'all', label: t('all')},
- {value: 'any', label: t('any')},
- {value: 'none', label: t('none')},
- ];
- const ACTION_MATCH_OPTIONS_MIGRATED = [
- {value: 'all', label: t('all')},
- {value: 'any', label: t('any')},
- ];
- const defaultRule: UnsavedIssueAlertRule = {
- actionMatch: 'any',
- filterMatch: 'all',
- actions: [],
- // note we update the default conditions in onLoadAllEndpointsSuccess
- conditions: [],
- filters: [],
- name: '',
- frequency: 60 * 24,
- environment: ALL_ENVIRONMENTS_KEY,
- };
- const POLLING_MAX_TIME_LIMIT = 3 * 60000;
- const SENTRY_ISSUE_ALERT_DOCS_URL =
- 'https://docs.sentry.io/product/alerts/alert-types/#issue-alerts';
- type ConditionOrActionProperty = 'conditions' | 'actions' | 'filters';
- type RuleTaskResponse = {
- status: 'pending' | 'failed' | 'success';
- error?: string;
- rule?: IssueAlertRule;
- };
- type RouteParams = {projectId?: string; ruleId?: string};
- export type IncompatibleRule = {
- conditionIndices: number[] | null;
- filterIndices: number[] | null;
- };
- type Props = {
- location: Location;
- members: Member[] | undefined;
- organization: Organization;
- project: Project;
- projects: Project[];
- userTeamIds: string[];
- loadingProjects?: boolean;
- onChangeTitle?: (data: string) => void;
- } & RouteComponentProps<RouteParams, {}>;
- type State = DeprecatedAsyncView['state'] & {
- configs: {
- actions: IssueAlertRuleActionTemplate[];
- conditions: IssueAlertRuleConditionTemplate[];
- filters: IssueAlertRuleConditionTemplate[];
- } | null;
- detailedError: null | {
- [key: string]: string[];
- };
- environments: Environment[] | null;
- incompatibleConditions: number[] | null;
- incompatibleFilters: number[] | null;
- issueCount: number;
- loadingPreview: boolean;
- previewCursor: string | null | undefined;
- previewEndpoint: null | string;
- previewError: null | string;
- previewGroups: string[] | null;
- previewPage: number;
- project: Project;
- sendingNotification: boolean;
- uuid: null | string;
- acceptedNoisyAlert?: boolean;
- duplicateTargetRule?: UnsavedIssueAlertRule | IssueAlertRule | null;
- ownership?: null | IssueOwnership;
- rule?: UnsavedIssueAlertRule | IssueAlertRule | null;
- };
- function isSavedAlertRule(rule: State['rule']): rule is IssueAlertRule {
- return rule?.hasOwnProperty('id') ?? false;
- }
- /**
- * Expecting "This rule is an exact duplicate of '{duplicate_rule.label}' in this project and may not be created."
- */
- const isExactDuplicateExp = /duplicate of '(.*)'/;
- class IssueRuleEditor extends DeprecatedAsyncView<Props, State> {
- pollingTimeout: number | undefined = undefined;
- trackIncompatibleAnalytics: boolean = false;
- trackNoisyWarningViewed: boolean = false;
- isUnmounted = false;
- get isDuplicateRule(): boolean {
- const {location} = this.props;
- const createFromDuplicate = location?.query.createFromDuplicate === 'true';
- return createFromDuplicate && location?.query.duplicateRuleId;
- }
- componentDidMount() {
- super.componentDidMount();
- this.fetchPreview();
- }
- componentWillUnmount() {
- super.componentWillUnmount();
- this.isUnmounted = true;
- GroupStore.reset();
- window.clearTimeout(this.pollingTimeout);
- this.checkIncompatibleRuleDebounced.cancel();
- this.fetchPreviewDebounced.cancel();
- }
- componentDidUpdate(_prevProps: Props, prevState: State) {
- if (prevState.previewCursor !== this.state.previewCursor) {
- this.fetchPreview();
- } else if (this.isRuleStateChange(prevState)) {
- this.setState({
- loadingPreview: true,
- incompatibleConditions: null,
- incompatibleFilters: null,
- });
- this.fetchPreviewDebounced();
- this.checkIncompatibleRuleDebounced();
- }
- if (prevState.project.id === this.state.project.id) {
- return;
- }
- this.fetchEnvironments();
- }
- isRuleStateChange(prevState: State): boolean {
- const prevRule = prevState.rule;
- const curRule = this.state.rule;
- return (
- JSON.stringify(prevRule?.conditions) !== JSON.stringify(curRule?.conditions) ||
- JSON.stringify(prevRule?.filters) !== JSON.stringify(curRule?.filters) ||
- prevRule?.actionMatch !== curRule?.actionMatch ||
- prevRule?.filterMatch !== curRule?.filterMatch ||
- prevRule?.frequency !== curRule?.frequency ||
- JSON.stringify(prevState.project) !== JSON.stringify(this.state.project)
- );
- }
- getTitle() {
- const {organization} = this.props;
- const {rule, project} = this.state;
- const ruleName = rule?.name;
- return routeTitleGen(
- ruleName ? t('Alert - %s', ruleName) : t('New Alert Rule'),
- organization.slug,
- false,
- project?.slug
- );
- }
- getDefaultState() {
- const {userTeamIds, project} = this.props;
- const defaultState = {
- ...super.getDefaultState(),
- configs: null,
- detailedError: null,
- rule: {...defaultRule},
- environments: [],
- uuid: null,
- project,
- previewGroups: null,
- previewCursor: null,
- previewError: null,
- issueCount: 0,
- previewPage: 0,
- loadingPreview: false,
- sendingNotification: false,
- incompatibleConditions: null,
- incompatibleFilters: null,
- previewEndpoint: null,
- };
- const projectTeamIds = new Set(project.teams.map(({id}) => id));
- const userTeamId = userTeamIds.find(id => projectTeamIds.has(id)) ?? null;
- defaultState.rule.owner = userTeamId && `team:${userTeamId}`;
- return defaultState;
- }
- getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
- const {
- location: {query},
- params: {ruleId},
- } = this.props;
- const {organization} = this.props;
- // project in state isn't initialized when getEndpoints is first called
- const project = this.state?.project ?? this.props.project;
- const endpoints = [
- [
- 'environments',
- `/projects/${organization.slug}/${project.slug}/environments/`,
- {
- query: {
- visibility: 'visible',
- },
- },
- ],
- ['configs', `/projects/${organization.slug}/${project.slug}/rules/configuration/`],
- ['ownership', `/projects/${organization.slug}/${project.slug}/ownership/`],
- ];
- if (ruleId) {
- endpoints.push([
- 'rule',
- `/projects/${organization.slug}/${project.slug}/rules/${ruleId}/`,
- ]);
- }
- if (!ruleId && query.createFromDuplicate && query.duplicateRuleId) {
- endpoints.push([
- 'duplicateTargetRule',
- `/projects/${organization.slug}/${project.slug}/rules/${query.duplicateRuleId}/`,
- ]);
- }
- return endpoints as [string, string][];
- }
- onRequestSuccess({stateKey, data}) {
- if (stateKey === 'rule' && data.name) {
- this.props.onChangeTitle?.(data.name);
- }
- if (stateKey === 'duplicateTargetRule') {
- this.setState({
- rule: {
- ...omit(data, ['id']),
- name: data.name + ' copy',
- } as UnsavedIssueAlertRule,
- });
- }
- }
- onLoadAllEndpointsSuccess() {
- const {rule} = this.state;
- const {
- params: {ruleId},
- } = this.props;
- if (rule) {
- ((rule as IssueAlertRule)?.errors || []).map(({detail}) =>
- addErrorMessage(detail, {append: true})
- );
- }
- if (!ruleId) {
- // now that we've loaded all the possible conditions, we can populate the
- // value of conditions for a new alert
- const id = 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition';
- this.handleChange('conditions', [
- {
- id,
- label: CHANGE_ALERT_PLACEHOLDERS_LABELS[id],
- },
- ]);
- }
- }
- pollHandler = async (quitTime: number) => {
- if (Date.now() > quitTime) {
- addErrorMessage(t('Looking for that channel took too long :('));
- this.setState({loading: false});
- return;
- }
- const {organization} = this.props;
- const {uuid, project} = this.state;
- const origRule = this.state.rule;
- try {
- const response: RuleTaskResponse = await this.api.requestPromise(
- `/projects/${organization.slug}/${project.slug}/rule-task/${uuid}/`
- );
- const {status, rule, error} = response;
- if (status === 'pending') {
- window.clearTimeout(this.pollingTimeout);
- this.pollingTimeout = window.setTimeout(() => {
- this.pollHandler(quitTime);
- }, 1000);
- return;
- }
- if (status === 'failed') {
- this.setState({
- detailedError: {actions: [error ? error : t('An error occurred')]},
- loading: false,
- });
- this.handleRuleSaveFailure(t('An error occurred'));
- }
- if (rule) {
- const ruleId = isSavedAlertRule(origRule) ? `${origRule.id}/` : '';
- const isNew = !ruleId;
- this.handleRuleSuccess(isNew, rule);
- }
- } catch {
- this.handleRuleSaveFailure(t('An error occurred'));
- this.setState({loading: false});
- }
- };
- fetchPreview = (resetCursor = false) => {
- const {organization} = this.props;
- const {project, rule, previewCursor, previewEndpoint} = this.state;
- if (!rule) {
- return;
- }
- this.setState({loadingPreview: true});
- if (resetCursor) {
- this.setState({previewCursor: null, previewPage: 0});
- }
- // we currently don't have a way to parse objects from query params, so this method is POST for now
- this.api
- .requestPromise(`/projects/${organization.slug}/${project.slug}/rules/preview/`, {
- method: 'POST',
- includeAllArgs: true,
- query: {
- cursor: resetCursor ? null : previewCursor,
- per_page: 5,
- },
- data: {
- conditions: rule?.conditions || [],
- filters: rule?.filters || [],
- actionMatch: rule?.actionMatch || 'all',
- filterMatch: rule?.filterMatch || 'all',
- frequency: rule?.frequency || 60,
- endpoint: previewEndpoint,
- },
- })
- .then(([data, _, resp]) => {
- if (this.isUnmounted) {
- return;
- }
- GroupStore.add(data);
- const pageLinks = resp?.getResponseHeader('Link');
- const hits = resp?.getResponseHeader('X-Hits');
- const endpoint = resp?.getResponseHeader('Endpoint');
- const issueCount =
- typeof hits !== 'undefined' && hits ? parseInt(hits, 10) || 0 : 0;
- this.setState({
- previewGroups: data.map(g => g.id),
- previewError: null,
- pageLinks: pageLinks ?? '',
- issueCount,
- loadingPreview: false,
- previewEndpoint: endpoint ?? null,
- });
- })
- .catch(_ => {
- const errorMessage =
- rule?.conditions.length || rule?.filters.length
- ? t('Preview is not supported for these conditions')
- : t('Select a condition to generate a preview');
- this.setState({
- previewError: errorMessage,
- loadingPreview: false,
- });
- });
- };
- fetchPreviewDebounced = debounce(() => {
- this.fetchPreview(true);
- }, 1000);
- // As more incompatible combinations are added, we will need a more generic way to check for incompatibility.
- checkIncompatibleRuleDebounced = debounce(() => {
- const {conditionIndices, filterIndices} = findIncompatibleRules(this.state.rule);
- if (
- !this.trackIncompatibleAnalytics &&
- (conditionIndices !== null || filterIndices !== null)
- ) {
- this.trackIncompatibleAnalytics = true;
- trackAnalytics('edit_alert_rule.incompatible_rule', {
- organization: this.props.organization,
- });
- }
- this.setState({
- incompatibleConditions: conditionIndices,
- incompatibleFilters: filterIndices,
- });
- }, 500);
- onPreviewCursor: CursorHandler = (cursor, _1, _2, direction) => {
- this.setState({
- previewCursor: cursor,
- previewPage: this.state.previewPage + direction,
- });
- };
- fetchEnvironments() {
- const {organization} = this.props;
- const {project} = this.state;
- this.api
- .requestPromise(`/projects/${organization.slug}/${project.slug}/environments/`, {
- query: {
- visibility: 'visible',
- },
- })
- .then(response => this.setState({environments: response}))
- .catch(_err => addErrorMessage(t('Unable to fetch environments')));
- }
- fetchStatus() {
- // pollHandler calls itself until it gets either a success
- // or failed status but we don't want to poll forever so we pass
- // in a hard stop time of 3 minutes before we bail.
- const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
- window.clearTimeout(this.pollingTimeout);
- this.pollingTimeout = window.setTimeout(() => {
- this.pollHandler(quitTime);
- }, 1000);
- }
- testNotifications = () => {
- const {organization} = this.props;
- const {project, rule} = this.state;
- this.setState({detailedError: null, sendingNotification: true});
- const actions = rule?.actions ? rule?.actions.length : 0;
- addLoadingMessage(
- tn('Sending a test notification...', 'Sending test notifications...', actions)
- );
- this.api
- .requestPromise(`/projects/${organization.slug}/${project.slug}/rule-actions/`, {
- method: 'POST',
- data: {
- actions: rule?.actions ?? [],
- },
- })
- .then(() => {
- addSuccessMessage(tn('Notification sent!', 'Notifications sent!', actions));
- trackAnalytics('edit_alert_rule.notification_test', {
- organization,
- success: true,
- });
- })
- .catch(error => {
- addErrorMessage(tn('Notification failed', 'Notifications failed', actions));
- this.setState({detailedError: error.responseJSON || null});
- trackAnalytics('edit_alert_rule.notification_test', {
- organization,
- success: false,
- });
- })
- .finally(() => {
- this.setState({sendingNotification: false});
- });
- };
- handleRuleSuccess = (isNew: boolean, rule: IssueAlertRule) => {
- const {organization, router} = this.props;
- const {project} = this.state;
- // The onboarding task will be completed on the server side when the alert
- // is created
- updateOnboardingTask(null, organization, {
- task: OnboardingTaskKey.ALERT_RULE,
- status: 'complete',
- });
- metric.endTransaction({name: 'saveAlertRule'});
- router.push(
- normalizeUrl({
- pathname: `/organizations/${organization.slug}/alerts/rules/${project.slug}/${rule.id}/details/`,
- })
- );
- addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
- };
- handleRuleSaveFailure(msg: ReactNode) {
- addErrorMessage(msg);
- metric.endTransaction({name: 'saveAlertRule'});
- }
- handleSubmit = async () => {
- const {project, rule} = this.state;
- const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
- const isNew = !ruleId;
- const {organization} = this.props;
- const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
- if (rule && rule.environment === ALL_ENVIRONMENTS_KEY) {
- delete rule.environment;
- }
- // Check conditions exist or they've accepted a noisy alert
- if (this.displayNoConditionsWarning() && !this.state.acceptedNoisyAlert) {
- this.setState({detailedError: {acceptedNoisyAlert: [t('Required')]}});
- return;
- }
- addLoadingMessage();
- try {
- const transaction = metric.startTransaction({name: 'saveAlertRule'});
- transaction.setTag('type', 'issue');
- transaction.setTag('operation', isNew ? 'create' : 'edit');
- if (rule) {
- for (const action of rule.actions) {
- // Grab the last part of something like 'sentry.mail.actions.NotifyEmailAction'
- const splitActionId = action.id.split('.');
- const actionName = splitActionId[splitActionId.length - 1];
- if (actionName === 'SlackNotifyServiceAction') {
- transaction.setTag(actionName, true);
- }
- // to avoid storing inconsistent data in the db, don't pass the name fields
- delete action.name;
- }
- for (const condition of rule.conditions) {
- delete condition.name;
- }
- for (const filter of rule.filters) {
- delete filter.name;
- }
- transaction.setData('actions', rule.actions);
- // Check if rule is currently disabled or going to be disabled
- if ('status' in rule && (rule.status === 'disabled' || !!rule.disableDate)) {
- rule.optOutEdit = true;
- }
- }
- const [data, , resp] = await this.api.requestPromise(endpoint, {
- includeAllArgs: true,
- method: isNew ? 'POST' : 'PUT',
- data: rule,
- query: {
- duplicateRule: this.isDuplicateRule ? 'true' : 'false',
- wizardV3: 'true',
- },
- });
- // if we get a 202 back it means that we have an async task
- // running to lookup and verify the channel id for Slack.
- if (resp?.status === 202) {
- this.setState({detailedError: null, loading: true, uuid: data.uuid});
- this.fetchStatus();
- addLoadingMessage(t('Looking through all your channels...'));
- } else {
- this.handleRuleSuccess(isNew, data);
- }
- } catch (err) {
- this.setState({
- detailedError: err.responseJSON || {__all__: 'Unknown error'},
- loading: false,
- });
- this.handleRuleSaveFailure(t('An error occurred'));
- }
- };
- handleDeleteRule = async () => {
- const {project, rule} = this.state;
- const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
- const isNew = !ruleId;
- const {organization} = this.props;
- if (isNew) {
- return;
- }
- const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
- addLoadingMessage(t('Deleting...'));
- try {
- await this.api.requestPromise(endpoint, {
- method: 'DELETE',
- });
- addSuccessMessage(t('Deleted alert rule'));
- browserHistory.replace(
- recreateRoute('', {
- ...this.props,
- params: {...this.props.params, orgId: organization.slug},
- stepBack: -2,
- })
- );
- } catch (err) {
- this.setState({
- detailedError: err.responseJSON || {__all__: 'Unknown error'},
- });
- addErrorMessage(t('There was a problem deleting the alert'));
- }
- };
- handleCancel = () => {
- const {organization, router} = this.props;
- router.push(normalizeUrl(`/organizations/${organization.slug}/alerts/rules/`));
- };
- hasError = (field: string) => {
- const {detailedError} = this.state;
- if (!detailedError) {
- return false;
- }
- return detailedError.hasOwnProperty(field);
- };
- handleEnvironmentChange = (val: string) => {
- // If 'All Environments' is selected the value should be null
- if (val === ALL_ENVIRONMENTS_KEY) {
- this.handleChange('environment', null);
- } else {
- this.handleChange('environment', val);
- }
- };
- handleChange = <T extends keyof IssueAlertRule>(prop: T, val: IssueAlertRule[T]) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- set(clonedState, `rule[${prop}]`, val);
- return {...clonedState, detailedError: omit(prevState.detailedError, prop)};
- });
- };
- handlePropertyChange = <T extends keyof IssueAlertRuleAction>(
- type: ConditionOrActionProperty,
- idx: number,
- prop: T,
- val: IssueAlertRuleAction[T]
- ) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- set(clonedState, `rule[${type}][${idx}][${prop}]`, val);
- return clonedState;
- });
- };
- getInitialValue = (type: ConditionOrActionProperty, id: string) => {
- const configuration = this.state.configs?.[type]?.find(c => c.id === id);
- const hasChangeAlerts =
- configuration?.id &&
- this.props.organization.features.includes('change-alerts') &&
- CHANGE_ALERT_CONDITION_IDS.includes(configuration.id);
- return configuration?.formFields
- ? Object.fromEntries(
- Object.entries(configuration.formFields)
- // TODO(ts): Doesn't work if I cast formField as IssueAlertRuleFormField
- .map(([key, formField]: [string, any]) => [
- key,
- hasChangeAlerts && key === 'interval'
- ? '1h'
- : formField?.initial ?? formField?.choices?.[0]?.[0],
- ])
- .filter(([, initial]) => !!initial)
- )
- : {};
- };
- handleResetRow = <T extends keyof IssueAlertRuleAction>(
- type: ConditionOrActionProperty,
- idx: number,
- prop: T,
- val: IssueAlertRuleAction[T]
- ) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- // Set initial configuration, but also set
- const id = (clonedState.rule as IssueAlertRule)[type][idx].id;
- const newRule = {
- ...this.getInitialValue(type, id),
- id,
- [prop]: val,
- };
- set(clonedState, `rule[${type}][${idx}]`, newRule);
- return clonedState;
- });
- };
- handleAddRow = (
- type: ConditionOrActionProperty,
- item: IssueAlertRuleActionTemplate
- ) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- // Set initial configuration
- const newRule = {
- ...this.getInitialValue(type, item.id),
- id: item.id,
- sentryAppInstallationUuid: item.sentryAppInstallationUuid,
- };
- const newTypeList = prevState.rule ? prevState.rule[type] : [];
- set(clonedState, `rule[${type}]`, [...newTypeList, newRule]);
- return clonedState;
- });
- const {organization} = this.props;
- const {project} = this.state;
- trackAnalytics('edit_alert_rule.add_row', {
- organization,
- project_id: project.id,
- type,
- name: item.id,
- });
- };
- handleDeleteRow = (type: ConditionOrActionProperty, idx: number) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- const newTypeList = prevState.rule ? [...prevState.rule[type]] : [];
- newTypeList.splice(idx, 1);
- set(clonedState, `rule[${type}]`, newTypeList);
- return clonedState;
- });
- };
- handleAddCondition = (template: IssueAlertRuleActionTemplate) =>
- this.handleAddRow('conditions', template);
- handleAddAction = (template: IssueAlertRuleActionTemplate) =>
- this.handleAddRow('actions', template);
- handleAddFilter = (template: IssueAlertRuleActionTemplate) =>
- this.handleAddRow('filters', template);
- handleDeleteCondition = (ruleIndex: number) =>
- this.handleDeleteRow('conditions', ruleIndex);
- handleDeleteAction = (ruleIndex: number) => this.handleDeleteRow('actions', ruleIndex);
- handleDeleteFilter = (ruleIndex: number) => this.handleDeleteRow('filters', ruleIndex);
- handleChangeConditionProperty = (ruleIndex: number, prop: string, val: string) =>
- this.handlePropertyChange('conditions', ruleIndex, prop, val);
- handleChangeActionProperty = (ruleIndex: number, prop: string, val: string) =>
- this.handlePropertyChange('actions', ruleIndex, prop, val);
- handleChangeFilterProperty = (ruleIndex: number, prop: string, val: string) =>
- this.handlePropertyChange('filters', ruleIndex, prop, val);
- handleResetCondition = (ruleIndex: number, prop: string, value: string) =>
- this.handleResetRow('conditions', ruleIndex, prop, value);
- handleResetAction = (ruleIndex: number, prop: string, value: string) =>
- this.handleResetRow('actions', ruleIndex, prop, value);
- handleResetFilter = (ruleIndex: number, prop: string, value: string) =>
- this.handleResetRow('filters', ruleIndex, prop, value);
- handleValidateRuleName = () => {
- const isRuleNameEmpty = !this.state.rule?.name.trim();
- if (!isRuleNameEmpty) {
- return;
- }
- this.setState(prevState => ({
- detailedError: {
- ...prevState.detailedError,
- name: [t('Field Required')],
- },
- }));
- };
- getConditions() {
- const {organization} = this.props;
- if (!organization.features.includes('change-alerts')) {
- return this.state.configs?.conditions ?? null;
- }
- return (
- this.state.configs?.conditions?.map(condition =>
- CHANGE_ALERT_CONDITION_IDS.includes(condition.id)
- ? ({
- ...condition,
- label: CHANGE_ALERT_PLACEHOLDERS_LABELS[condition.id],
- } as IssueAlertRuleConditionTemplate)
- : condition
- ) ?? null
- );
- }
- getTeamId = () => {
- const {rule} = this.state;
- const owner = rule?.owner;
- // ownership follows the format team:<id>, just grab the id
- return owner && owner.split(':')[1];
- };
- handleOwnerChange = ({value}: {value: string}) => {
- const ownerValue = value && `team:${value}`;
- this.handleChange('owner', ownerValue);
- };
- renderLoading() {
- return this.renderBody();
- }
- renderError() {
- return (
- <Alert type="error" showIcon>
- {t(
- 'Unable to access this alert rule -- check to make sure you have the correct permissions'
- )}
- </Alert>
- );
- }
- renderRuleName(disabled: boolean) {
- const {rule, detailedError} = this.state;
- const {name} = rule || {};
- // Duplicate errors display on the "name" field but we're showing them in a banner
- // Remove them from the name detailed error
- const filteredDetailedError =
- detailedError?.name?.filter(str => !isExactDuplicateExp.test(str)) ?? [];
- return (
- <StyledField
- label={null}
- help={null}
- error={filteredDetailedError[0]}
- disabled={disabled}
- required
- stacked
- flexibleControlStateSize
- >
- <Input
- type="text"
- name="name"
- value={name}
- data-test-id="alert-name"
- placeholder={t('Enter Alert Name')}
- onChange={(event: ChangeEvent<HTMLInputElement>) =>
- this.handleChange('name', event.target.value)
- }
- onBlur={this.handleValidateRuleName}
- disabled={disabled}
- />
- </StyledField>
- );
- }
- renderTeamSelect(disabled: boolean) {
- const {rule, project} = this.state;
- const ownerId = rule?.owner?.split(':')[1];
- return (
- <StyledField label={null} help={null} disabled={disabled} flexibleControlStateSize>
- <TeamSelector
- value={this.getTeamId()}
- project={project}
- onChange={this.handleOwnerChange}
- teamFilter={(team: Team) =>
- team.isMember || team.id === ownerId || team.access.includes('team:admin')
- }
- useId
- includeUnassigned
- disabled={disabled}
- />
- </StyledField>
- );
- }
- renderDuplicateErrorAlert() {
- const {organization} = this.props;
- const {detailedError, project} = this.state;
- const duplicateName = isExactDuplicateExp.exec(detailedError?.name?.[0] ?? '')?.[1];
- const duplicateRuleId = detailedError?.ruleId?.[0] ?? '';
- // We want this to open in a new tab to not lose the current state of the rule editor
- return (
- <AlertLink
- openInNewTab
- priority="error"
- icon={<IconNot color="red300" />}
- href={normalizeUrl(
- `/organizations/${organization.slug}/alerts/rules/${project.slug}/${duplicateRuleId}/details/`
- )}
- >
- {tct(
- 'This rule fully duplicates "[alertName]" in the project [projectName] and cannot be saved.',
- {
- alertName: duplicateName,
- projectName: project.name,
- }
- )}
- </AlertLink>
- );
- }
- displayNoConditionsWarning(): boolean {
- const {rule} = this.state;
- const acceptedNoisyActionIds = [
- // Webhooks
- 'sentry.rules.actions.notify_event_service.NotifyEventServiceAction',
- // Legacy integrations
- 'sentry.rules.actions.notify_event.NotifyEventAction',
- ];
- return (
- this.props.organization.features.includes('noisy-alert-warning') &&
- !!rule &&
- !isSavedAlertRule(rule) &&
- rule.conditions.length === 0 &&
- rule.filters.length === 0 &&
- !rule.actions.every(action => acceptedNoisyActionIds.includes(action.id))
- );
- }
- renderAcknowledgeNoConditions(disabled: boolean) {
- const {detailedError, acceptedNoisyAlert} = this.state;
- // Bit goofy to do in render but should only track onceish
- if (!this.trackNoisyWarningViewed) {
- this.trackNoisyWarningViewed = true;
- trackAnalytics('alert_builder.noisy_warning_viewed', {
- organization: this.props.organization,
- });
- }
- return (
- <Alert type="warning" showIcon>
- <div>
- {t(
- 'Alerts without conditions can fire too frequently. Are you sure you want to save this alert rule?'
- )}
- </div>
- <AcknowledgeField
- label={null}
- help={null}
- error={detailedError?.acceptedNoisyAlert?.[0]}
- disabled={disabled}
- required
- stacked
- flexibleControlStateSize
- inline
- >
- <AcknowledgeLabel>
- <Checkbox
- size="sm"
- name="acceptedNoisyAlert"
- checked={acceptedNoisyAlert}
- onChange={() => {
- this.setState({acceptedNoisyAlert: !acceptedNoisyAlert});
- if (!acceptedNoisyAlert) {
- trackAnalytics('alert_builder.noisy_warning_agreed', {
- organization: this.props.organization,
- });
- }
- }}
- disabled={disabled}
- />
- {t('Yes, I don’t mind if this alert gets noisy')}
- </AcknowledgeLabel>
- </AcknowledgeField>
- </Alert>
- );
- }
- renderIdBadge(project: Project) {
- return (
- <IdBadge
- project={project}
- avatarProps={{consistentWidth: true}}
- avatarSize={18}
- disableLink
- hideName
- />
- );
- }
- renderEnvironmentSelect(disabled: boolean) {
- const {environments, rule} = this.state;
- const environmentOptions = [
- {
- value: ALL_ENVIRONMENTS_KEY,
- label: t('All Environments'),
- },
- ...(environments?.map(env => ({value: env.name, label: getDisplayName(env)})) ??
- []),
- ];
- const environment =
- !rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
- return (
- <FormField
- name="environment"
- inline={false}
- style={{padding: 0, border: 'none'}}
- flexibleControlStateSize
- className={this.hasError('environment') ? ' error' : ''}
- required
- disabled={disabled}
- >
- {({onChange, onBlur}) => (
- <SelectControl
- clearable={false}
- disabled={disabled}
- value={environment}
- options={environmentOptions}
- onChange={({value}) => {
- this.handleEnvironmentChange(value);
- onChange(value, {});
- onBlur(value, {});
- }}
- />
- )}
- </FormField>
- );
- }
- renderPreviewText() {
- const {issueCount, previewError} = this.state;
- if (previewError) {
- return t(
- "Select a condition above to see which issues would've triggered this alert"
- );
- }
- return tct(
- "[issueCount] issues would have triggered this rule in the past 14 days [approximately:approximately]. If you're looking to reduce noise then make sure to [link:read the docs].",
- {
- issueCount,
- approximately: (
- <Tooltip
- title={t('Previews that include issue frequency conditions are approximated')}
- showUnderline
- />
- ),
- link: <ExternalLink href={SENTRY_ISSUE_ALERT_DOCS_URL} />,
- }
- );
- }
- renderPreviewTable() {
- const {members} = this.props;
- const {
- previewGroups,
- previewError,
- pageLinks,
- issueCount,
- previewPage,
- loadingPreview,
- } = this.state;
- return (
- <PreviewTable
- previewGroups={previewGroups}
- members={members}
- pageLinks={pageLinks}
- onCursor={this.onPreviewCursor}
- issueCount={issueCount}
- page={previewPage}
- loading={loadingPreview}
- error={previewError}
- />
- );
- }
- renderProjectSelect(disabled: boolean) {
- const {project: _selectedProject, projects, organization} = this.props;
- const {rule} = this.state;
- const projectOptions = getProjectOptions({
- organization,
- projects,
- isFormDisabled: disabled,
- });
- return (
- <FormField
- name="projectId"
- inline={false}
- style={{padding: 0}}
- flexibleControlStateSize
- >
- {({onChange, onBlur, model}) => {
- const selectedProject =
- projects.find(({id}) => id === model.getValue('projectId')) ||
- _selectedProject;
- return (
- <SelectControl
- disabled={disabled || isSavedAlertRule(rule)}
- value={selectedProject.id}
- styles={{
- container: (provided: {[x: string]: string | number | boolean}) => ({
- ...provided,
- marginBottom: `${space(1)}`,
- }),
- }}
- options={projectOptions}
- onChange={({value}: {value: Project['id']}) => {
- // if the current owner/team isn't part of project selected, update to the first available team
- const nextSelectedProject =
- projects.find(({id}) => id === value) ?? selectedProject;
- const ownerId: string | undefined = model
- .getValue('owner')
- ?.split(':')[1];
- if (
- ownerId &&
- nextSelectedProject.teams.find(({id}) => id === ownerId) ===
- undefined &&
- nextSelectedProject.teams.length
- ) {
- this.handleOwnerChange({value: nextSelectedProject.teams[0].id});
- }
- this.setState({project: nextSelectedProject});
- onChange(value, {});
- onBlur(value, {});
- }}
- components={{
- SingleValue: containerProps => (
- <components.ValueContainer {...containerProps}>
- <IdBadge
- project={selectedProject}
- avatarProps={{consistentWidth: true}}
- avatarSize={18}
- disableLink
- />
- </components.ValueContainer>
- ),
- }}
- />
- );
- }}
- </FormField>
- );
- }
- renderActionInterval(disabled: boolean) {
- const {rule} = this.state;
- const {frequency} = rule || {};
- return (
- <FormField
- name="frequency"
- inline={false}
- style={{padding: 0, border: 'none'}}
- label={null}
- help={null}
- className={this.hasError('frequency') ? ' error' : ''}
- required
- disabled={disabled}
- flexibleControlStateSize
- >
- {({onChange, onBlur}) => (
- <SelectControl
- clearable={false}
- disabled={disabled}
- value={`${frequency}`}
- options={FREQUENCY_OPTIONS}
- onChange={({value}) => {
- this.handleChange('frequency', value);
- onChange(value, {});
- onBlur(value, {});
- }}
- />
- )}
- </FormField>
- );
- }
- renderBody() {
- const {organization} = this.props;
- const {
- project,
- rule,
- detailedError,
- loading,
- ownership,
- sendingNotification,
- incompatibleConditions,
- incompatibleFilters,
- } = this.state;
- const {actions, filters, conditions, frequency} = rule || {};
- const environment =
- !rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
- const canCreateAlert = hasEveryAccess(['alerts:write'], {organization, project});
- const disabled = loading || !(canCreateAlert || isActiveSuperuser());
- const displayDuplicateError =
- detailedError?.name?.some(str => isExactDuplicateExp.test(str)) ?? false;
- // Note `key` on `<Form>` below is so that on initial load, we show
- // the form with a loading mask on top of it, but force a re-render by using
- // a different key when we have fetched the rule so that form inputs are filled in
- return (
- <Main fullWidth>
- <PermissionAlert access={['alerts:write']} project={project} />
- <StyledForm
- key={isSavedAlertRule(rule) ? rule.id : undefined}
- onCancel={this.handleCancel}
- onSubmit={this.handleSubmit}
- initialData={{
- ...rule,
- environment,
- frequency: `${frequency}`,
- projectId: project.id,
- }}
- submitDisabled={
- disabled || incompatibleConditions !== null || incompatibleFilters !== null
- }
- submitLabel={t('Save Rule')}
- extraButton={
- isSavedAlertRule(rule) ? (
- <Confirm
- disabled={disabled}
- priority="danger"
- confirmText={t('Delete Rule')}
- onConfirm={this.handleDeleteRule}
- header={<h5>{t('Delete Alert Rule?')}</h5>}
- message={t(
- 'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.',
- rule.name
- )}
- >
- <Button priority="danger">{t('Delete Rule')}</Button>
- </Confirm>
- ) : null
- }
- >
- <List symbol="colored-numeric">
- {loading && <SemiTransparentLoadingMask data-test-id="loading-mask" />}
- <StyledListItem>
- <StepHeader>{t('Select an environment and project')}</StepHeader>
- </StyledListItem>
- <ContentIndent>
- <SettingsContainer>
- {this.renderEnvironmentSelect(disabled)}
- {this.renderProjectSelect(disabled)}
- </SettingsContainer>
- </ContentIndent>
- <SetConditionsListItem>
- <StepHeader>{t('Set conditions')}</StepHeader>
- <SetupAlertIntegrationButton
- projectSlug={project.slug}
- organization={organization}
- />
- </SetConditionsListItem>
- <ContentIndent>
- <ConditionsPanel>
- <PanelBody>
- <Step>
- <StepConnector />
- <StepContainer>
- <ChevronContainer>
- <IconChevron
- color="gray200"
- isCircled
- direction="right"
- size="sm"
- />
- </ChevronContainer>
- <StepContent>
- <StepLead>
- {tct(
- '[when:When] an event is captured by Sentry and [selector] of the following happens',
- {
- when: <Badge />,
- selector: (
- <EmbeddedWrapper>
- <EmbeddedSelectField
- className={classNames({
- error: this.hasError('actionMatch'),
- })}
- styles={{
- control: provided => ({
- ...provided,
- minHeight: '21px',
- height: '21px',
- }),
- }}
- inline={false}
- isSearchable={false}
- isClearable={false}
- name="actionMatch"
- required
- flexibleControlStateSize
- options={ACTION_MATCH_OPTIONS_MIGRATED}
- onChange={val =>
- this.handleChange('actionMatch', val)
- }
- size="xs"
- disabled={disabled}
- />
- </EmbeddedWrapper>
- ),
- }
- )}
- </StepLead>
- <RuleNodeList
- nodes={this.getConditions()}
- items={conditions ?? []}
- selectType="grouped"
- placeholder={t('Add optional trigger...')}
- onPropertyChange={this.handleChangeConditionProperty}
- onAddRow={this.handleAddCondition}
- onResetRow={this.handleResetCondition}
- onDeleteRow={this.handleDeleteCondition}
- organization={organization}
- project={project}
- disabled={disabled}
- error={
- this.hasError('conditions') && (
- <StyledAlert type="error">
- {detailedError?.conditions[0]}
- {(detailedError?.conditions[0] || '').startsWith(
- 'You may not exceed'
- ) && (
- <Fragment>
- {' '}
- <ExternalLink href="https://docs.sentry.io/product/alerts/create-alerts/#alert-limits">
- {t('View Docs')}
- </ExternalLink>
- </Fragment>
- )}
- </StyledAlert>
- )
- }
- incompatibleRules={incompatibleConditions}
- incompatibleBanner={
- incompatibleFilters === null &&
- incompatibleConditions !== null
- ? incompatibleConditions.at(-1)
- : null
- }
- />
- </StepContent>
- </StepContainer>
- </Step>
- <Step>
- <StepConnector />
- <StepContainer>
- <ChevronContainer>
- <IconChevron
- color="gray200"
- isCircled
- direction="right"
- size="sm"
- />
- </ChevronContainer>
- <StepContent>
- <StepLead>
- {tct('[if:If][selector] of these filters match', {
- if: <Badge />,
- selector: (
- <EmbeddedWrapper>
- <EmbeddedSelectField
- className={classNames({
- error: this.hasError('filterMatch'),
- })}
- styles={{
- control: provided => ({
- ...provided,
- minHeight: '21px',
- height: '21px',
- }),
- }}
- inline={false}
- isSearchable={false}
- isClearable={false}
- name="filterMatch"
- required
- flexibleControlStateSize
- options={ACTION_MATCH_OPTIONS}
- onChange={val => this.handleChange('filterMatch', val)}
- size="xs"
- disabled={disabled}
- />
- </EmbeddedWrapper>
- ),
- })}
- </StepLead>
- <RuleNodeList
- nodes={this.state.configs?.filters ?? null}
- items={filters ?? []}
- placeholder={t('Add optional filter...')}
- onPropertyChange={this.handleChangeFilterProperty}
- onAddRow={this.handleAddFilter}
- onResetRow={this.handleResetFilter}
- onDeleteRow={this.handleDeleteFilter}
- organization={organization}
- project={project}
- disabled={disabled}
- error={
- this.hasError('filters') && (
- <StyledAlert type="error">
- {detailedError?.filters[0]}
- </StyledAlert>
- )
- }
- incompatibleRules={incompatibleFilters}
- incompatibleBanner={
- incompatibleFilters ? incompatibleFilters.at(-1) : null
- }
- />
- </StepContent>
- </StepContainer>
- </Step>
- <Step>
- <StepContainer>
- <ChevronContainer>
- <IconChevron
- isCircled
- color="gray200"
- direction="right"
- size="sm"
- />
- </ChevronContainer>
- <StepContent>
- <StepLead>
- {tct('[then:Then] perform these actions', {
- then: <Badge />,
- })}
- </StepLead>
- <RuleNodeList
- nodes={this.state.configs?.actions ?? null}
- selectType="grouped"
- items={actions ?? []}
- placeholder={t('Add action...')}
- onPropertyChange={this.handleChangeActionProperty}
- onAddRow={this.handleAddAction}
- onResetRow={this.handleResetAction}
- onDeleteRow={this.handleDeleteAction}
- organization={organization}
- project={project}
- disabled={disabled}
- ownership={ownership}
- error={
- this.hasError('actions') && (
- <StyledAlert type="error">
- {detailedError?.actions[0]}
- </StyledAlert>
- )
- }
- />
- <TestButtonWrapper>
- <Button
- onClick={this.testNotifications}
- disabled={sendingNotification || rule?.actions?.length === 0}
- >
- {t('Send Test Notification')}
- </Button>
- </TestButtonWrapper>
- </StepContent>
- </StepContainer>
- </Step>
- </PanelBody>
- </ConditionsPanel>
- </ContentIndent>
- <StyledListItem>
- <StepHeader>{t('Set action interval')}</StepHeader>
- <StyledFieldHelp>
- {t('Perform the actions above once this often for an issue')}
- </StyledFieldHelp>
- </StyledListItem>
- <ContentIndent>{this.renderActionInterval(disabled)}</ContentIndent>
- <StyledListItem>
- <StyledListItemSpaced>
- <div>
- <StepHeader>{t('Preview')}</StepHeader>
- <StyledFieldHelp>{this.renderPreviewText()}</StyledFieldHelp>
- </div>
- </StyledListItemSpaced>
- </StyledListItem>
- <ContentIndent>{this.renderPreviewTable()}</ContentIndent>
- <StyledListItem>
- <StepHeader>{t('Add a name and owner')}</StepHeader>
- <StyledFieldHelp>
- {t(
- 'This name will show up in notifications and the owner will give permissions to your whole team to edit and view this alert.'
- )}
- </StyledFieldHelp>
- </StyledListItem>
- <ContentIndent>
- <StyledFieldWrapper>
- {this.renderRuleName(disabled)}
- {this.renderTeamSelect(disabled)}
- </StyledFieldWrapper>
- {displayDuplicateError && this.renderDuplicateErrorAlert()}
- {this.displayNoConditionsWarning() &&
- this.renderAcknowledgeNoConditions(disabled)}
- </ContentIndent>
- </List>
- </StyledForm>
- </Main>
- );
- }
- }
- export default withOrganization(withProjects(IssueRuleEditor));
- export const findIncompatibleRules = (
- rule: IssueAlertRule | UnsavedIssueAlertRule | null | undefined
- ): IncompatibleRule => {
- if (!rule) {
- return {conditionIndices: null, filterIndices: null};
- }
- const {conditions, filters} = rule;
- // Check for more than one 'issue state change' condition
- // or 'FirstSeenEventCondition' + 'EventFrequencyCondition'
- if (rule.actionMatch === 'all') {
- let firstSeen = -1;
- let regression = -1;
- let reappeared = -1;
- let eventFrequency = -1;
- let userFrequency = -1;
- for (let i = 0; i < conditions.length; i++) {
- const id = conditions[i].id;
- if (id.endsWith('FirstSeenEventCondition')) {
- firstSeen = i;
- } else if (id.endsWith('RegressionEventCondition')) {
- regression = i;
- } else if (id.endsWith('ReappearedEventCondition')) {
- reappeared = i;
- } else if (
- id.endsWith('EventFrequencyCondition') &&
- (conditions[i].value as number) >= 1
- ) {
- eventFrequency = i;
- } else if (
- id.endsWith('EventUniqueUserFrequencyCondition') &&
- (conditions[i].value as number) >= 1
- ) {
- userFrequency = i;
- }
- // FirstSeenEventCondition is incompatible with all the following types
- const firstSeenError =
- firstSeen !== -1 &&
- [regression, reappeared, eventFrequency, userFrequency].some(idx => idx !== -1);
- const regressionReappearedError = regression !== -1 && reappeared !== -1;
- if (firstSeenError || regressionReappearedError) {
- const indices = [firstSeen, regression, reappeared, eventFrequency, userFrequency]
- .filter(idx => idx !== -1)
- .sort((a, b) => a - b);
- return {conditionIndices: indices, filterIndices: null};
- }
- }
- }
- // Check for 'FirstSeenEventCondition' and ('IssueOccurrencesFilter' or 'AgeComparisonFilter')
- // Considers the case where filterMatch is 'any' and all filters are incompatible
- const firstSeen = conditions.findIndex(condition =>
- condition.id.endsWith('FirstSeenEventCondition')
- );
- if (firstSeen !== -1 && (rule.actionMatch === 'all' || conditions.length === 1)) {
- let incompatibleFilters = 0;
- for (let i = 0; i < filters.length; i++) {
- const filter = filters[i];
- const id = filter.id;
- if (id.endsWith('IssueOccurrencesFilter') && filter) {
- if (
- (rule.filterMatch === 'all' && (filter.value as number) > 1) ||
- (rule.filterMatch === 'none' && (filter.value as number) <= 1)
- ) {
- return {conditionIndices: [firstSeen], filterIndices: [i]};
- }
- if (rule.filterMatch === 'any' && (filter.value as number) > 1) {
- incompatibleFilters += 1;
- }
- } else if (id.endsWith('AgeComparisonFilter')) {
- if (rule.filterMatch !== 'none') {
- if (filter.comparison_type === 'older') {
- if (rule.filterMatch === 'all') {
- return {conditionIndices: [firstSeen], filterIndices: [i]};
- }
- incompatibleFilters += 1;
- }
- } else if (filter.comparison_type === 'newer' && (filter.value as number) > 0) {
- return {conditionIndices: [firstSeen], filterIndices: [i]};
- }
- }
- }
- if (incompatibleFilters === filters.length && incompatibleFilters > 0) {
- return {
- conditionIndices: [firstSeen],
- filterIndices: [...Array(filters.length).keys()],
- };
- }
- }
- return {conditionIndices: null, filterIndices: null};
- };
- const Main = styled(Layout.Main)`
- max-width: 1000px;
- `;
- // TODO(ts): Understand why styled is not correctly inheriting props here
- const StyledForm = styled(Form)<FormProps>`
- position: relative;
- `;
- const ConditionsPanel = styled(Panel)`
- padding-top: ${space(0.5)};
- padding-bottom: ${space(2)};
- `;
- const StyledAlert = styled(Alert)`
- margin-bottom: 0;
- `;
- const StyledListItem = styled(ListItem)`
- margin: ${space(2)} 0 ${space(1)} 0;
- font-size: ${p => p.theme.fontSizeExtraLarge};
- `;
- const StyledListItemSpaced = styled('div')`
- display: flex;
- justify-content: space-between;
- `;
- const StyledFieldHelp = styled(FieldHelp)`
- margin-top: 0;
- @media (max-width: ${p => p.theme.breakpoints.small}) {
- margin-left: -${space(4)};
- }
- `;
- const SetConditionsListItem = styled(StyledListItem)`
- display: flex;
- justify-content: space-between;
- `;
- const Step = styled('div')`
- position: relative;
- display: flex;
- align-items: flex-start;
- margin: ${space(4)} ${space(4)} ${space(3)} ${space(1)};
- `;
- const StepHeader = styled('h5')`
- margin-bottom: ${space(1)};
- `;
- const StepContainer = styled('div')`
- position: relative;
- display: flex;
- align-items: flex-start;
- flex-grow: 1;
- `;
- const StepContent = styled('div')`
- flex-grow: 1;
- `;
- const StepConnector = styled('div')`
- position: absolute;
- height: 100%;
- top: 28px;
- left: 19px;
- border-right: 1px ${p => p.theme.gray200} dashed;
- `;
- const StepLead = styled('div')`
- margin-bottom: ${space(0.5)};
- display: flex;
- align-items: center;
- gap: ${space(0.5)};
- `;
- const TestButtonWrapper = styled('div')`
- margin-top: ${space(1.5)};
- `;
- const ChevronContainer = styled('div')`
- display: flex;
- align-items: center;
- padding: ${space(0.5)} ${space(1.5)};
- `;
- const Badge = styled('span')`
- min-width: 56px;
- background-color: ${p => p.theme.purple300};
- padding: 0 ${space(0.75)};
- border-radius: ${p => p.theme.borderRadius};
- color: ${p => p.theme.white};
- text-transform: uppercase;
- text-align: center;
- font-size: ${p => p.theme.fontSizeMedium};
- font-weight: 600;
- line-height: 1.5;
- `;
- const EmbeddedWrapper = styled('div')`
- width: 80px;
- `;
- const EmbeddedSelectField = styled(SelectField)`
- padding: 0;
- font-weight: normal;
- text-transform: none;
- `;
- const SemiTransparentLoadingMask = styled(LoadingMask)`
- opacity: 0.6;
- z-index: 1; /* Needed so that it sits above form elements */
- `;
- const SettingsContainer = styled('div')`
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: ${space(1)};
- `;
- const StyledField = styled(FieldGroup)`
- border-bottom: none;
- padding: 0;
- & > div {
- padding: 0;
- width: 100%;
- }
- margin-bottom: ${space(1)};
- `;
- const StyledFieldWrapper = styled('div')`
- @media (min-width: ${p => p.theme.breakpoints.small}) {
- display: grid;
- grid-template-columns: 2fr 1fr;
- gap: ${space(1)};
- }
- `;
- const ContentIndent = styled('div')`
- @media (min-width: ${p => p.theme.breakpoints.small}) {
- margin-left: ${space(4)};
- }
- `;
- const AcknowledgeLabel = styled('label')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
- line-height: 2;
- font-weight: normal;
- `;
- const AcknowledgeField = styled(FieldGroup)`
- padding: 0;
- display: flex;
- align-items: center;
- margin-top: ${space(1)};
- & > div {
- padding-left: 0;
- display: flex;
- align-items: baseline;
- flex: unset;
- gap: ${space(1)};
- }
- `;
|