12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283 |
- import {ChangeEvent, 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 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 Input from 'sentry/components/input';
- 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 space from 'sentry/styles/space';
- import {
- Environment,
- IssueOwnership,
- 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 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 = {
- location: Location;
- 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;
- ownership?: null | IssueOwnership;
- 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} = this.props;
- const createFromDuplicate = location?.query.createFromDuplicate === 'true';
- return createFromDuplicate && location?.query.duplicateRuleId;
- }
- componentWillUnmount() {
- window.clearTimeout(this.pollingTimeout);
- }
- 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 {
- 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 endpoints = [
- [
- 'environments',
- `/projects/${orgId}/${project.slug}/environments/`,
- {
- query: {
- visibility: 'visible',
- },
- },
- ],
- ['configs', `/projects/${orgId}/${project.slug}/rules/configuration/`],
- ['ownership', `/projects/${orgId}/${project.slug}/ownership/`],
- ];
- if (ruleId) {
- endpoints.push(['rule', `/projects/${orgId}/${project.slug}/rules/${ruleId}/`]);
- }
- if (!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: 'true',
- },
- });
- // if we get a 202 back it means that we have an async task
- // running to lookup and verify the channel id for Slack.
- if (resp?.status === 202) {
- this.setState({detailedError: null, loading: true, uuid: data.uuid});
- this.fetchStatus();
- addLoadingMessage(t('Looking through all your channels...'));
- } else {
- this.handleRuleSuccess(isNew, data);
- }
- } catch (err) {
- this.setState({
- detailedError: err.responseJSON || {__all__: 'Unknown error'},
- loading: false,
- });
- this.handleRuleSaveFailure(t('An error occurred'));
- }
- };
- handleDeleteRule = async () => {
- const {project, rule} = this.state;
- const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
- const isNew = !ruleId;
- const {organization} = this.props;
- if (isNew) {
- return;
- }
- const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
- addLoadingMessage(t('Deleting...'));
- try {
- await this.api.requestPromise(endpoint, {
- method: 'DELETE',
- });
- addSuccessMessage(t('Deleted alert rule'));
- browserHistory.replace(recreateRoute('', {...this.props, 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
- label={null}
- help={null}
- error={detailedError?.name?.[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
- extraMargin
- label={null}
- help={null}
- disabled={disabled}
- flexibleControlStateSize
- >
- <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
- />
- );
- }
- 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 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 || isSavedAlertRule(rule)}
- value={selectedProject.id}
- styles={{
- container: (provided: {[x: string]: string | number | boolean}) => ({
- ...provided,
- marginBottom: `${space(1)}`,
- }),
- }}
- options={projectOptions}
- onChange={({value}: {value: Project['id']}) => {
- // if the current owner/team isn't part of project selected, update to the first available team
- const nextSelectedProject =
- projects.find(({id}) => id === value) ?? selectedProject;
- const ownerId: string | undefined = model
- .getValue('owner')
- ?.split(':')[1];
- if (
- ownerId &&
- nextSelectedProject.teams.find(({id}) => id === ownerId) ===
- undefined &&
- nextSelectedProject.teams.length
- ) {
- this.handleOwnerChange({value: nextSelectedProject.teams[0].id});
- }
- this.setState({project: nextSelectedProject});
- onChange(value, {});
- onBlur(value, {});
- }}
- components={{
- SingleValue: containerProps => (
- <components.ValueContainer {...containerProps}>
- <IdBadge
- project={selectedProject}
- avatarProps={{consistentWidth: true}}
- avatarSize={18}
- disableLink
- />
- </components.ValueContainer>
- ),
- }}
- />
- );
- }}
- </FormField>
- );
- }
- renderActionInterval(disabled: boolean) {
- const {rule} = this.state;
- const {frequency} = rule || {};
- return (
- <FormField
- name="frequency"
- inline={false}
- style={{padding: 0, border: 'none'}}
- label={null}
- help={null}
- className={this.hasError('frequency') ? ' error' : ''}
- required
- disabled={disabled}
- flexibleControlStateSize
- >
- {({onChange, onBlur}) => (
- <SelectControl
- clearable={false}
- disabled={disabled}
- value={`${frequency}`}
- options={FREQUENCY_OPTIONS}
- onChange={({value}) => {
- this.handleChange('frequency', value);
- onChange(value, {});
- onBlur(value, {});
- }}
- />
- )}
- </FormField>
- );
- }
- renderBody() {
- const {organization} = this.props;
- const {project, rule, detailedError, loading, ownership} = this.state;
- const {actions, filters, conditions, frequency} = rule || {};
- 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>
- <SettingsContainer>
- {this.renderEnvironmentSelect(disabled)}
- {this.renderProjectSelect(disabled)}
- </SettingsContainer>
- <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}
- ownership={ownership}
- 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.renderActionInterval(disabled)}
- <StyledListItem>{t('Establish ownership')}</StyledListItem>
- {this.renderRuleName(disabled)}
- {this.renderTeamSelect(disabled)}
- </List>
- </StyledForm>
- </Main>
- );
- }}
- </Access>
- );
- }
- }
- export default withOrganization(withProjects(IssueRuleEditor));
- // 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}>`
- :last-child {
- padding-bottom: ${space(2)};
- }
- border-bottom: none;
- padding: 0;
- & > div {
- padding: 0;
- width: 100%;
- }
- margin-bottom: ${p => `${p.extraMargin ? '60px' : space(1)}`};
- `;
- const Main = styled(Layout.Main)`
- padding: ${space(2)} ${space(4)};
- `;
|