12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769 |
- import type {ChangeEvent, ReactNode} from 'react';
- import {Fragment} from 'react';
- import {components} from 'react-select';
- import styled from '@emotion/styled';
- import * as Sentry from '@sentry/react';
- import classNames from 'classnames';
- import type {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 ErrorBoundary from 'sentry/components/errorBoundary';
- 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 type {FormProps} from 'sentry/components/forms/form';
- import Form 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 Panel from 'sentry/components/panels/panel';
- import PanelBody from 'sentry/components/panels/panelBody';
- import TeamSelector from 'sentry/components/teamSelector';
- import {ALL_ENVIRONMENTS_KEY} from 'sentry/constants';
- import {IconChevron, IconNot} from 'sentry/icons';
- import {t, tct, tn} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {
- IssueAlertConfiguration,
- IssueAlertRule,
- IssueAlertRuleAction,
- IssueAlertRuleActionTemplate,
- UnsavedIssueAlertRule,
- } from 'sentry/types/alerts';
- import {
- IssueAlertActionType,
- IssueAlertConditionType,
- IssueAlertFilterType,
- } from 'sentry/types/alerts';
- import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
- import {OnboardingTaskKey} from 'sentry/types/onboarding';
- import type {Member, Organization, Team} from 'sentry/types/organization';
- import type {Environment, Project} from 'sentry/types/project';
- import {metric, trackAnalytics} from 'sentry/utils/analytics';
- import {browserHistory} from 'sentry/utils/browserHistory';
- 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/url/normalizeUrl';
- import withOrganization from 'sentry/utils/withOrganization';
- import withProjects from 'sentry/utils/withProjects';
- import FeedbackAlertBanner from 'sentry/views/alerts/rules/issue/feedbackAlertBanner';
- import {PreviewIssues} from 'sentry/views/alerts/rules/issue/previewIssues';
- import SetupMessagingIntegrationButton, {
- MessagingIntegrationAnalyticsView,
- } from 'sentry/views/alerts/rules/issue/setupMessagingIntegrationButton';
- 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';
- 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;
- type ConfigurationKey = keyof IssueAlertConfiguration;
- 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: IssueAlertConfiguration | null;
- detailedError: null | {
- [key: string]: string[];
- };
- environments: Environment[] | null;
- incompatibleConditions: number[] | null;
- incompatibleFilters: number[] | null;
- project: Project;
- sendingNotification: boolean;
- acceptedNoisyAlert?: boolean;
- duplicateTargetRule?: UnsavedIssueAlertRule | IssueAlertRule | null;
- 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 = false;
- trackNoisyWarningViewed = false;
- isUnmounted = false;
- uuid: string | null = null;
- get isDuplicateRule(): boolean {
- const {location} = this.props;
- const createFromDuplicate = location?.query.createFromDuplicate === 'true';
- return createFromDuplicate && location?.query.duplicateRuleId;
- }
- componentDidMount() {
- super.componentDidMount();
- }
- componentWillUnmount() {
- super.componentWillUnmount();
- this.isUnmounted = true;
- window.clearTimeout(this.pollingTimeout);
- this.checkIncompatibleRuleDebounced.cancel();
- }
- componentDidUpdate(_prevProps: Props, prevState: State) {
- if (this.isRuleStateChange(prevState)) {
- this.setState({
- incompatibleConditions: null,
- incompatibleFilters: null,
- });
- this.checkIncompatibleRuleDebounced();
- }
- if (prevState.project.id === this.state.project.id) {
- return;
- }
- this.fetchEnvironments();
- this.refetchConfigs();
- }
- 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: [],
- project,
- sendingNotification: false,
- incompatibleConditions: null,
- incompatibleFilters: 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/`],
- ];
- 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 && !this.isDuplicateRule) {
- // now that we've loaded all the possible conditions, we can populate the
- // value of conditions for a new alert
- this.handleChange('conditions', [{id: IssueAlertConditionType.FIRST_SEEN_EVENT}]);
- }
- }
- 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 {project} = this.state;
- const origRule = this.state.rule;
- try {
- const response: RuleTaskResponse = await this.api.requestPromise(
- `/projects/${organization.slug}/${project.slug}/rule-task/${this.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});
- }
- };
- // 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);
- 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')));
- }
- refetchConfigs = () => {
- const {organization} = this.props;
- const {project} = this.state;
- this.api
- .requestPromise(
- `/projects/${organization.slug}/${project.slug}/rules/configuration/`
- )
- .then(response => this.setState({configs: response}))
- .catch(() => {
- // No need to alert user if this fails, can use existing data
- });
- };
- 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.endSpan({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.endSpan({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();
- await Sentry.withScope(async scope => {
- try {
- scope.setTag('type', 'issue');
- scope.setTag('operation', isNew ? 'create' : 'edit');
- if (rule) {
- for (const action of rule.actions) {
- if (action.id === IssueAlertActionType.SLACK) {
- scope?.setTag('SlackNotifyServiceAction', true);
- }
- // to avoid storing inconsistent data in the db, don't pass the name fields
- delete action.name;
- }
- for (const condition of rule.conditions) {
- // values of 0 must be manually changed to strings, otherwise they will be interpreted as missing by the serializer
- if ('value' in condition && condition.value === 0) {
- condition.value = '0';
- }
- delete condition.name;
- }
- for (const filter of rule.filters) {
- delete filter.name;
- }
- scope.setExtra('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;
- }
- }
- metric.startSpan({name: 'saveAlertRule'});
- 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.uuid = data.uuid;
- this.setState({detailedError: null, loading: true});
- 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: ConfigurationKey,
- 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: ConfigurationKey,
- id: string
- ): IssueAlertConfiguration[ConfigurationKey] => {
- 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: ConfigurationKey,
- 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: ConfigurationKey, 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: ConfigurationKey, 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);
- const {organization} = this.props;
- const {project} = this.state;
- const deletedItem = prevState.rule ? prevState.rule[type][idx] : null;
- trackAnalytics('edit_alert_rule.delete_row', {
- organization,
- project_id: project.id,
- type,
- name: deletedItem?.id ?? '',
- });
- 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(): IssueAlertConfiguration['conditions'] | null {
- const {organization} = this.props;
- if (!organization.features.includes('change-alerts')) {
- return this.state.configs?.conditions ?? null;
- }
- let conditions = this.state.configs?.conditions ?? null;
- if (conditions === null) {
- return null;
- }
- if (
- !organization.features.includes(
- 'event-unique-user-frequency-condition-with-conditions'
- )
- ) {
- conditions = conditions?.filter(
- condition =>
- condition.id !==
- 'sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyConditionWithConditions'
- );
- }
- conditions = conditions?.map(condition =>
- CHANGE_ALERT_CONDITION_IDS.includes(condition.id)
- ? {
- ...condition,
- label: `${CHANGE_ALERT_PLACEHOLDERS_LABELS[condition.id]}...`,
- }
- : condition
- );
- return conditions;
- }
- getTeamId = () => {
- const {rule} = this.state;
- const owner = rule?.owner;
- // ownership follows the format team:<id>, just grab the id
- return 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: string[] = [
- // Webhooks
- IssueAlertActionType.NOTIFY_EVENT_SERVICE_ACTION,
- // Legacy integrations
- IssueAlertActionType.NOTIFY_EVENT_ACTION,
- ];
- return (
- !!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>
- );
- }
- 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, members} = this.props;
- const {
- project,
- rule,
- detailedError,
- loading,
- 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>{' '}
- <SetupMessagingIntegrationButton
- projectId={project.id}
- refetchConfigs={this.refetchConfigs}
- analyticsParams={{
- view: MessagingIntegrationAnalyticsView.ALERT_RULE_CREATION,
- }}
- />
- </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 data-test-id="rule-filters">
- <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
- }
- />
- <FeedbackAlertBanner
- filters={this.state.rule?.filters}
- projectSlug={this.state.project.slug}
- />
- </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>
- )
- }
- additionalAction={{
- label: 'Notify integration\u{2026}',
- option: {
- label: 'Missing an integration? Click here to refresh',
- value: {
- enabled: true,
- id: 'refresh_configs',
- label: 'Refresh Integration List',
- },
- },
- onClick: () => {
- this.refetchConfigs();
- },
- }}
- />
- <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>
- <ErrorBoundary mini>
- <PreviewIssues members={members} rule={rule} project={project} />
- </ErrorBoundary>
- <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 === IssueAlertConditionType.FIRST_SEEN_EVENT) {
- firstSeen = i;
- } else if (id === IssueAlertConditionType.REGRESSION_EVENT) {
- regression = i;
- } else if (id === IssueAlertConditionType.REAPPEARED_EVENT) {
- reappeared = i;
- } else if (
- id === IssueAlertConditionType.EVENT_FREQUENCY &&
- (conditions[i].value as number) >= 1
- ) {
- eventFrequency = i;
- } else if (
- id === IssueAlertConditionType.EVENT_UNIQUE_USER_FREQUENCY &&
- (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 === IssueAlertFilterType.ISSUE_OCCURRENCES && 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 === IssueAlertFilterType.AGE_COMPARISON) {
- 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 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: ${p => p.theme.fontWeightBold};
- line-height: 1.5;
- `;
- const EmbeddedWrapper = styled('div')`
- width: 80px;
- `;
- const EmbeddedSelectField = styled(SelectField)`
- padding: 0;
- font-weight: ${p => p.theme.fontWeightNormal};
- 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: ${p => p.theme.fontWeightNormal};
- `;
- 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)};
- }
- `;
|