123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353 |
- 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 omit from 'lodash/omit';
- import set from 'lodash/set';
- import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
- } from 'sentry/actionCreators/indicator';
- import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
- import Access from 'sentry/components/acl/access';
- import Alert from 'sentry/components/alert';
- import Button from 'sentry/components/button';
- import Confirm from 'sentry/components/confirm';
- import Input from 'sentry/components/forms/controls/input';
- import Field from 'sentry/components/forms/field';
- import FieldHelp from 'sentry/components/forms/field/fieldHelp';
- import Form from 'sentry/components/forms/form';
- import FormField from 'sentry/components/forms/formField';
- import SelectControl from 'sentry/components/forms/selectControl';
- import SelectField from 'sentry/components/forms/selectField';
- import TeamSelector from 'sentry/components/forms/teamSelector';
- import IdBadge from 'sentry/components/idBadge';
- import * as Layout from 'sentry/components/layouts/thirds';
- import List from 'sentry/components/list';
- import ListItem from 'sentry/components/list/listItem';
- import LoadingMask from 'sentry/components/loadingMask';
- import {Panel, PanelBody} from 'sentry/components/panels';
- import {ALL_ENVIRONMENTS_KEY} from 'sentry/constants';
- import {IconChevron} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import ConfigStore from 'sentry/stores/configStore';
- import HookStore from 'sentry/stores/hookStore';
- import space from 'sentry/styles/space';
- import {Environment, OnboardingTaskKey, Organization, Project, Team} from 'sentry/types';
- import {
- IssueAlertRule,
- IssueAlertRuleAction,
- IssueAlertRuleActionTemplate,
- IssueAlertRuleConditionTemplate,
- UnsavedIssueAlertRule,
- } from 'sentry/types/alerts';
- import {metric} from 'sentry/utils/analytics';
- import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
- 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 withExperiment from 'sentry/utils/withExperiment';
- import withOrganization from 'sentry/utils/withOrganization';
- import withProjects from 'sentry/utils/withProjects';
- import {
- CHANGE_ALERT_CONDITION_IDS,
- CHANGE_ALERT_PLACEHOLDERS_LABELS,
- } from 'sentry/views/alerts/utils/constants';
- import AsyncView from 'sentry/views/asyncView';
- 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: 'all',
- filterMatch: 'all',
- actions: [],
- conditions: [],
- filters: [],
- name: '',
- frequency: 30,
- environment: ALL_ENVIRONMENTS_KEY,
- };
- const POLLING_MAX_TIME_LIMIT = 3 * 60000;
- type ConditionOrActionProperty = 'conditions' | 'actions' | 'filters';
- type RuleTaskResponse = {
- status: 'pending' | 'failed' | 'success';
- error?: string;
- rule?: IssueAlertRule;
- };
- type RouteParams = {orgId: string; projectId?: string; ruleId?: string};
- type Props = {
- experimentAssignment: 0 | 1;
- location: Location;
- logExperiment: () => void;
- organization: Organization;
- project: Project;
- projects: Project[];
- userTeamIds: string[];
- loadingProjects?: boolean;
- onChangeTitle?: (data: string) => void;
- } & RouteComponentProps<RouteParams, {}>;
- type State = AsyncView['state'] & {
- configs: {
- actions: IssueAlertRuleActionTemplate[];
- conditions: IssueAlertRuleConditionTemplate[];
- filters: IssueAlertRuleConditionTemplate[];
- } | null;
- detailedError: null | {
- [key: string]: string[];
- };
- environments: Environment[] | null;
- project: Project;
- uuid: null | string;
- duplicateTargetRule?: UnsavedIssueAlertRule | IssueAlertRule | null;
- rule?: UnsavedIssueAlertRule | IssueAlertRule | null;
- };
- function isSavedAlertRule(rule: State['rule']): rule is IssueAlertRule {
- return rule?.hasOwnProperty('id') ?? false;
- }
- class IssueRuleEditor extends AsyncView<Props, State> {
- pollingTimeout: number | undefined = undefined;
- get isDuplicateRule(): boolean {
- const {location, organization} = this.props;
- const createFromDuplicate = location?.query.createFromDuplicate === 'true';
- const hasDuplicateAlertRules = organization.features.includes('duplicate-alert-rule');
- return (
- hasDuplicateAlertRules && createFromDuplicate && location?.query.duplicateRuleId
- );
- }
- get hasAlertWizardV3(): boolean {
- return this.props.organization.features.includes('alert-wizard-v3');
- }
- componentWillUnmount() {
- window.clearTimeout(this.pollingTimeout);
- }
- componentDidMount() {
- const {params, organization, experimentAssignment, logExperiment} = this.props;
- // only new rules
- if (params.ruleId) {
- return;
- }
- // check if there is a callback registered
- const callback = HookStore.get('callback:default-action-alert-rule')[0];
- if (!callback) {
- return;
- }
- // let hook decide when we want to select a default alert rule
- callback((showDefaultAction: boolean) => {
- if (showDefaultAction) {
- const user = ConfigStore.get('user');
- const {rule} = this.state;
- // always log the experiment if we meet the basic requirements decided by the hook
- logExperiment();
- if (experimentAssignment) {
- // this will add a default alert rule action
- // to send notifications in
- this.setState({
- rule: {
- ...rule,
- actions: [
- {
- id: 'sentry.mail.actions.NotifyEmailAction',
- targetIdentifier: user.id,
- targetType: 'Member',
- } as any, // Need to fix IssueAlertRuleAction typing
- ],
- } as UnsavedIssueAlertRule,
- });
- }
- }
- }, organization);
- }
- componentDidUpdate(_prevProps: Props, prevState: State) {
- if (prevState.project.id === this.state.project.id) {
- return;
- }
- this.fetchEnvironments();
- }
- getTitle() {
- const {organization} = this.props;
- const {rule, project} = this.state;
- const ruleName = rule?.name;
- return routeTitleGen(
- ruleName ? t('Alert %s', ruleName) : '',
- 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,
- };
- 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<AsyncView['getEndpoints']> {
- const {
- organization,
- location: {query},
- params: {ruleId, orgId},
- } = this.props;
- // project in state isn't initialized when getEndpoints is first called
- const project = this.state?.project ?? this.props.project;
- const hasDuplicateAlertRules = organization.features.includes('duplicate-alert-rule');
- const endpoints = [
- ['environments', `/projects/${orgId}/${project.slug}/environments/`],
- ['configs', `/projects/${orgId}/${project.slug}/rules/configuration/`],
- ];
- if (ruleId) {
- endpoints.push(['rule', `/projects/${orgId}/${project.slug}/rules/${ruleId}/`]);
- }
- if (
- hasDuplicateAlertRules &&
- !ruleId &&
- query.createFromDuplicate &&
- query.duplicateRuleId
- ) {
- endpoints.push([
- 'duplicateTargetRule',
- `/projects/${orgId}/${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;
- if (rule) {
- ((rule as IssueAlertRule)?.errors || []).map(({detail}) =>
- addErrorMessage(detail, {append: true})
- );
- }
- }
- 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});
- }
- };
- fetchEnvironments() {
- const {
- params: {orgId},
- } = this.props;
- const {project} = this.state;
- this.api
- .requestPromise(`/projects/${orgId}/${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);
- }
- handleRuleSuccess = (isNew: boolean, rule: IssueAlertRule) => {
- const {organization, router} = this.props;
- const {project} = this.state;
- this.setState({detailedError: null, loading: false, rule});
- // 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({
- 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;
- }
- 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);
- }
- }
- transaction.setData('actions', rule.actions);
- }
- const [data, , resp] = await this.api.requestPromise(endpoint, {
- includeAllArgs: true,
- method: isNew ? 'POST' : 'PUT',
- data: rule,
- query: {
- duplicateRule: this.isDuplicateRule ? 'true' : 'false',
- wizardV3: this.hasAlertWizardV3 ? 'true' : 'false',
- },
- });
- // 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, 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(`/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, id: string) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- // Set initial configuration
- const newRule = {
- ...this.getInitialValue(type, id),
- id,
- };
- const newTypeList = prevState.rule ? prevState.rule[type] : [];
- set(clonedState, `rule[${type}]`, [...newTypeList, newRule]);
- return clonedState;
- });
- const {organization} = this.props;
- const {project} = this.state;
- trackAdvancedAnalyticsEvent('edit_alert_rule.add_row', {
- organization,
- project_id: project.id,
- type,
- name: id,
- });
- };
- handleDeleteRow = (type: ConditionOrActionProperty, idx: number) => {
- this.setState(prevState => {
- const clonedState = cloneDeep(prevState);
- const newTypeList = prevState.rule ? prevState.rule[type] : [];
- if (prevState.rule) {
- newTypeList.splice(idx, 1);
- }
- set(clonedState, `rule[${type}]`, newTypeList);
- return clonedState;
- });
- };
- handleAddCondition = (id: string) => this.handleAddRow('conditions', id);
- handleAddAction = (id: string) => this.handleAddRow('actions', id);
- handleAddFilter = (id: string) => this.handleAddRow('filters', id);
- 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 || {};
- return (
- <StyledField
- hasAlertWizardV3={this.hasAlertWizardV3}
- label={this.hasAlertWizardV3 ? null : t('Alert name')}
- help={this.hasAlertWizardV3 ? null : t('Add a name for this alert')}
- error={detailedError?.name?.[0]}
- disabled={disabled}
- required
- stacked
- flexibleControlStateSize={this.hasAlertWizardV3 ? true : undefined}
- >
- <Input
- type="text"
- name="name"
- value={name}
- data-test-id="alert-name"
- placeholder={this.hasAlertWizardV3 ? t('Enter Alert Name') : t('My Rule 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
- hasAlertWizardV3={this.hasAlertWizardV3}
- extraMargin
- label={this.hasAlertWizardV3 ? null : t('Team')}
- help={this.hasAlertWizardV3 ? null : t('The team that can edit this alert.')}
- disabled={disabled}
- flexibleControlStateSize={this.hasAlertWizardV3 ? true : undefined}
- >
- <TeamSelector
- value={this.getTeamId()}
- project={project}
- onChange={this.handleOwnerChange}
- teamFilter={(team: Team) => team.isMember || team.id === ownerId}
- useId
- includeUnassigned
- disabled={disabled}
- />
- </StyledField>
- );
- }
- renderIdBadge(project: Project) {
- return (
- <IdBadge
- project={project}
- avatarProps={{consistentWidth: true}}
- avatarSize={18}
- disableLink
- hideName
- />
- );
- }
- renderProjectSelect(disabled: boolean) {
- const {project: _selectedProject, projects, organization} = this.props;
- const hasOpenMembership = organization.features.includes('open-membership');
- const myProjects = projects.filter(project => project.hasAccess && project.isMember);
- const allProjects = projects.filter(
- project => project.hasAccess && !project.isMember
- );
- const myProjectOptions = myProjects.map(myProject => ({
- value: myProject.id,
- label: myProject.slug,
- leadingItems: this.renderIdBadge(myProject),
- }));
- const openMembershipProjects = [
- {
- label: t('My Projects'),
- options: myProjectOptions,
- },
- {
- label: t('All Projects'),
- options: allProjects.map(allProject => ({
- value: allProject.id,
- label: allProject.slug,
- leadingItems: this.renderIdBadge(allProject),
- })),
- },
- ];
- const projectOptions =
- hasOpenMembership || isActiveSuperuser()
- ? openMembershipProjects
- : myProjectOptions;
- 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}
- 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 (
- <StyledSelectField
- hasAlertWizardV3={this.hasAlertWizardV3}
- label={this.hasAlertWizardV3 ? null : t('Action Interval')}
- help={
- this.hasAlertWizardV3
- ? null
- : t('Perform these actions once this often for an issue')
- }
- clearable={false}
- name="frequency"
- className={this.hasError('frequency') ? ' error' : ''}
- value={frequency}
- required
- options={FREQUENCY_OPTIONS}
- onChange={val => this.handleChange('frequency', val)}
- disabled={disabled}
- flexibleControlStateSize={this.hasAlertWizardV3 ? true : undefined}
- />
- );
- }
- renderBody() {
- const {organization} = this.props;
- const {environments, project, rule, detailedError, loading} = this.state;
- const {actions, filters, conditions, frequency} = rule || {};
- 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;
- // 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 (
- <Access access={['alerts:write']}>
- {({hasAccess}) => {
- // check if superuser or if user is on the alert's team
- const disabled = loading || !(isActiveSuperuser() || hasAccess);
- return (
- <Main fullWidth>
- <StyledForm
- key={isSavedAlertRule(rule) ? rule.id : undefined}
- onCancel={this.handleCancel}
- onSubmit={this.handleSubmit}
- initialData={{
- ...rule,
- environment,
- frequency: `${frequency}`,
- projectId: project.id,
- }}
- submitDisabled={disabled}
- submitLabel={t('Save Rule')}
- extraButton={
- isSavedAlertRule(rule) ? (
- <Confirm
- disabled={disabled}
- priority="danger"
- confirmText={t('Delete Rule')}
- onConfirm={this.handleDeleteRule}
- header={t('Delete Rule')}
- message={t('Are you sure you want to delete this rule?')}
- >
- <Button priority="danger" type="button">
- {t('Delete Rule')}
- </Button>
- </Confirm>
- ) : null
- }
- >
- <List symbol="colored-numeric">
- {loading && <SemiTransparentLoadingMask data-test-id="loading-mask" />}
- <StyledListItem>{t('Add alert settings')}</StyledListItem>
- {this.hasAlertWizardV3 ? (
- <SettingsContainer>
- <StyledSelectField
- hasAlertWizardV3={this.hasAlertWizardV3}
- className={classNames({
- error: this.hasError('environment'),
- })}
- placeholder={t('Select an Environment')}
- clearable={false}
- name="environment"
- options={environmentOptions}
- onChange={val => this.handleEnvironmentChange(val)}
- disabled={disabled}
- flexibleControlStateSize
- />
- {this.renderProjectSelect(disabled)}
- </SettingsContainer>
- ) : (
- <Panel>
- <PanelBody>
- <SelectField
- className={classNames({
- error: this.hasError('environment'),
- })}
- label={t('Environment')}
- help={t(
- 'Choose an environment for these conditions to apply to'
- )}
- placeholder={t('Select an Environment')}
- clearable={false}
- name="environment"
- options={environmentOptions}
- onChange={val => this.handleEnvironmentChange(val)}
- disabled={disabled}
- />
- {this.renderTeamSelect(disabled)}
- {this.renderRuleName(disabled)}
- </PanelBody>
- </Panel>
- )}
- <SetConditionsListItem>
- {t('Set conditions')}
- <SetupAlertIntegrationButton
- projectSlug={project.slug}
- organization={organization}
- />
- </SetConditionsListItem>
- <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'),
- })}
- inline={false}
- styles={{
- control: provided => ({
- ...provided,
- minHeight: '20px',
- height: '20px',
- }),
- }}
- isSearchable={false}
- isClearable={false}
- name="actionMatch"
- required
- flexibleControlStateSize
- options={ACTION_MATCH_OPTIONS_MIGRATED}
- onChange={val =>
- this.handleChange('actionMatch', val)
- }
- 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]}
- </StyledAlert>
- )
- }
- />
- </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'),
- })}
- inline={false}
- styles={{
- control: provided => ({
- ...provided,
- minHeight: '20px',
- height: '20px',
- }),
- }}
- isSearchable={false}
- isClearable={false}
- name="filterMatch"
- required
- flexibleControlStateSize
- options={ACTION_MATCH_OPTIONS}
- onChange={val =>
- this.handleChange('filterMatch', val)
- }
- 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>
- )
- }
- />
- </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}
- error={
- this.hasError('actions') && (
- <StyledAlert type="error">
- {detailedError?.actions[0]}
- </StyledAlert>
- )
- }
- />
- </StepContent>
- </StepContainer>
- </Step>
- </PanelBody>
- </ConditionsPanel>
- <StyledListItem>
- {t('Set action interval')}
- <StyledFieldHelp>
- {t('Perform the actions above once this often for an issue')}
- </StyledFieldHelp>
- </StyledListItem>
- {this.hasAlertWizardV3 ? (
- this.renderActionInterval(disabled)
- ) : (
- <Panel>
- <PanelBody>{this.renderActionInterval(disabled)}</PanelBody>
- </Panel>
- )}
- {this.hasAlertWizardV3 && (
- <Fragment>
- <StyledListItem>{t('Establish ownership')}</StyledListItem>
- {this.renderRuleName(disabled)}
- {this.renderTeamSelect(disabled)}
- </Fragment>
- )}
- </List>
- </StyledForm>
- </Main>
- );
- }}
- </Access>
- );
- }
- }
- export default withExperiment(withOrganization(withProjects(IssueRuleEditor)), {
- experiment: 'DefaultIssueAlertActionExperiment',
- injectLogExperiment: true,
- });
- // TODO(ts): Understand why styled is not correctly inheriting props here
- const StyledForm = styled(Form)<Form['props']>`
- 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 StyledFieldHelp = styled(FieldHelp)`
- margin-top: 0;
- `;
- 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 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)};
- `;
- const ChevronContainer = styled('div')`
- display: flex;
- align-items: center;
- padding: ${space(0.5)} ${space(1.5)};
- `;
- const Badge = styled('span')`
- display: inline-block;
- 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')`
- display: inline-block;
- margin: 0 ${space(0.5)};
- 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(Field)<{extraMargin?: boolean; hasAlertWizardV3?: boolean}>`
- :last-child {
- padding-bottom: ${space(2)};
- }
- ${p =>
- p.hasAlertWizardV3 &&
- `
- border-bottom: none;
- padding: 0;
- & > div {
- padding: 0;
- width: 100%;
- }
- margin-bottom: ${p.extraMargin ? '60px' : space(1)};
- `}
- `;
- const StyledSelectField = styled(SelectField)<{hasAlertWizardV3?: boolean}>`
- ${p =>
- p.hasAlertWizardV3 &&
- `
- border-bottom: none;
- padding: 0;
- & > div {
- padding: 0;
- width: 100%;
- }
- margin-bottom: ${space(1)};
- `}
- `;
- const Main = styled(Layout.Main)`
- padding: ${space(2)} ${space(4)};
- `;
|