import {Fragment, useCallback, useEffect} from 'react'; import styled from '@emotion/styled'; import merge from 'lodash/merge'; import {openModal} from 'sentry/actionCreators/modal'; import {Alert} from 'sentry/components/alert'; import {Button, LinkButton} from 'sentry/components/button'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import Input from 'sentry/components/input'; import ExternalLink from 'sentry/components/links/externalLink'; import NumberInput from 'sentry/components/numberInput'; import {releaseHealth} from 'sentry/data/platformCategories'; import {IconDelete, IconSettings} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { IssueAlertConfiguration, IssueAlertRuleAction, IssueAlertRuleCondition, } from 'sentry/types/alerts'; import { AssigneeTargetType, IssueAlertActionType, IssueAlertConditionType, IssueAlertFilterType, MailActionTargetType, } from 'sentry/types/alerts'; import type {Choices} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import MemberTeamFields from 'sentry/views/alerts/rules/issue/memberTeamFields'; import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleModal'; import TicketRuleModal from 'sentry/views/alerts/rules/issue/ticketRuleModal'; import type {SchemaFormConfig} from 'sentry/views/settings/organizationIntegrations/sentryAppExternalForm'; interface FieldProps { data: Props['data']; disabled: boolean; fieldConfig: FormField; index: number; name: string; onMemberTeamChange: (data: Props['data']) => void; onPropertyChange: Props['onPropertyChange']; onReset: Props['onReset']; organization: Organization; project: Project; } function NumberField({ data, index, disabled, name, fieldConfig, onPropertyChange, }: FieldProps) { const value = (data[name] && typeof data[name] !== 'boolean') || data[name] === 0 ? Number(data[name]) : NaN; // Set default value of number fields to the placeholder value useEffect(() => { if ( data.id === IssueAlertFilterType.ISSUE_OCCURRENCES && isNaN(value) && !isNaN(Number(fieldConfig.placeholder)) ) { onPropertyChange(index, name, `${fieldConfig.placeholder}`); } // Value omitted on purpose to avoid overwriting user changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [onPropertyChange, index, name, fieldConfig.placeholder, data.id]); return ( onPropertyChange(index, name, String(newVal))} aria-label={t('Value')} /> ); } function AssigneeFilterFields({ data, organization, project, disabled, onMemberTeamChange, }: FieldProps) { const isInitialized = data.targetType !== undefined && `${data.targetType}`.length > 0; return ( ); } function MailActionFields({ data, organization, project, disabled, onMemberTeamChange, }: FieldProps) { const isInitialized = data.targetType !== undefined && `${data.targetType}`.length > 0; const issueOwnersLabel = t('Suggested Assignees'); return ( ); } function ChoiceField({ data, disabled, index, onPropertyChange, onReset, name, fieldConfig, }: FieldProps) { // Select the first item on this list // If it's not yet defined, call onPropertyChange to make sure the value is set on state let initialVal: string | undefined; if (data[name] === undefined && !!fieldConfig.choices.length) { initialVal = fieldConfig.initial ? `${fieldConfig.initial}` : `${fieldConfig.choices[0][0]}`; } else { initialVal = `${data[name]}`; } // All `value`s are cast to string // There are integrations that give the form field choices with the value as number, but // when the integration configuration gets saved, it gets saved and returned as a string const options = fieldConfig.choices.map(([value, label]) => ({ value: `${value}`, label, })); return ( ({ ...provided, minHeight: '28px', height: '28px', }), }} disabled={disabled} options={options} onChange={({value}: {value: string}) => { if (fieldConfig.resetsForm) { onReset(index, name, value); } else { onPropertyChange(index, name, value); } }} /> ); } function TextField({ data, index, onPropertyChange, disabled, name, fieldConfig, }: FieldProps) { const value = data[name] && typeof data[name] !== 'boolean' ? (data[name] as string | number) : ''; return ( ) => onPropertyChange(index, name, e.target.value) } /> ); } export type FormField = { // The rest is configuration for the form field [key: string]: any; // Type of form fields type: string; }; interface Props { data: IssueAlertRuleAction | IssueAlertRuleCondition; disabled: boolean; index: number; onDelete: (rowIndex: number) => void; onPropertyChange: (rowIndex: number, name: string, value: string) => void; onReset: (rowIndex: number, name: string, value: string) => void; organization: Organization; project: Project; incompatibleBanner?: boolean; incompatibleRule?: boolean; node?: IssueAlertConfiguration[keyof IssueAlertConfiguration][number] | null; } function RuleNode({ index, data, node, organization, project, disabled, onDelete, onPropertyChange, onReset, incompatibleRule, incompatibleBanner, }: Props) { const handleDelete = useCallback(() => { onDelete(index); }, [index, onDelete]); const handleMemberTeamChange = useCallback( ({targetType, targetIdentifier}: IssueAlertRuleAction | IssueAlertRuleCondition) => { onPropertyChange(index, 'targetType', `${targetType}`); onPropertyChange(index, 'targetIdentifier', `${targetIdentifier}`); }, [index, onPropertyChange] ); function getField(name: string, fieldConfig: FormField) { const fieldProps: FieldProps = { index, name, fieldConfig, data, organization, project, disabled, onMemberTeamChange: handleMemberTeamChange, onPropertyChange, onReset, }; if (name === 'environment') { return ( [env, env])}, })} /> ); } switch (fieldConfig.type) { case 'choice': return ; case 'number': return ; case 'string': return ; case 'mailAction': return ; case 'assignee': return ; default: return null; } } function renderRow() { if (!node) { return ( This node failed to render. It may have migrated to another section of the alert conditions ); } let {label} = node; if ( data.id === IssueAlertActionType.NOTIFY_EMAIL && data.targetType !== MailActionTargetType.ISSUE_OWNERS ) { // Hide the fallback options when targeting team or member label = 'Send a notification to {targetType}'; } if (data.id === IssueAlertConditionType.REAPPEARED_EVENT) { label = t('The issue changes state from archived to escalating'); } const parts = label.split(/({\w+})/).map((part, i) => { if (!/^{\w+}$/.test(part)) { return {part}; } const key = part.slice(1, -1); // If matcher is "is set" or "is not set", then we do not want to show the value input // because it is not required if (key === 'value' && (data.match === 'is' || data.match === 'ns')) { return null; } return ( {node.formFields?.hasOwnProperty(key) ? getField(key, node.formFields[key]) : part} ); }); const [title, ...inputs] = parts; // We return this so that it can be a grid return ( {title} {inputs} ); } /** * Displays a button to open a custom modal for sentry apps or ticket integrations */ function renderIntegrationButton() { if (!node || !('actionType' in node)) { return null; } if (node.actionType === 'ticket') { return ( ); } if (node.actionType === 'sentryapp' && node.sentryAppInstallationUuid) { return ( ); } return null; } function conditionallyRenderHelpfulBanner() { if (data.id === IssueAlertConditionType.EVENT_FREQUENCY_PERCENT) { if (!project.platform || !releaseHealth.includes(project.platform)) { return ( {tct( "This project doesn't support sessions. [link:View supported platforms]", { link: ( ), } )} ); } return ( {tct( 'Percent of sessions affected is approximated by the ratio of the issue frequency to the number of sessions in the project. [link:Learn more.]', { link: ( ), } )} ); } if (data.id === IssueAlertActionType.SLACK) { return ( {t('Learn More')} } > {t('Having rate limiting problems? Enter a channel or user ID.')} ); } if (data.id === IssueAlertActionType.DISCORD) { return ( {t('Learn More')} } > {t('Note that you must enter a Discord channel ID, not a channel name.')} ); } return null; } function renderIncompatibleRuleBanner() { if (!incompatibleBanner) { return null; } return ( {t( 'The conditions highlighted in red are in conflict. They may prevent the alert from ever being triggered.' )} ); } /** * Update all the AlertRuleAction's fields from the TicketRuleModal together * only after the user clicks "Apply Changes". * @param formData Form data * @param fetchedFieldOptionsCache Object */ const updateParentFromTicketRule = useCallback( ( formData: Record, fetchedFieldOptionsCache: Record ): void => { // We only know the choices after the form loads. formData.dynamic_form_fields = ((formData.dynamic_form_fields as any) || []).map( (field: any) => { // Overwrite the choices because the user's pick is in this list. if ( field.name in formData && fetchedFieldOptionsCache?.hasOwnProperty(field.name) ) { field.choices = fetchedFieldOptionsCache[field.name]; } return field; } ); for (const [name, value] of Object.entries(formData)) { onPropertyChange(index, name, value); } }, [index, onPropertyChange] ); /** * Update all the AlertRuleAction's fields from the SentryAppRuleModal together * only after the user clicks "Save Changes". * @param formData Form data */ const updateParentFromSentryAppRule = useCallback( (formData: Record): void => { for (const [name, value] of Object.entries(formData)) { onPropertyChange(index, name, value); } }, [index, onPropertyChange] ); return ( {renderRow()} {renderIntegrationButton()} } /> {renderIncompatibleRuleBanner()} {conditionallyRenderHelpfulBanner()} ); } export default RuleNode; const InlineInput = styled(Input)` width: auto; height: 28px; min-height: 28px; `; const InlineNumberInput = styled(NumberInput)` width: 90px; height: 28px; min-height: 28px; `; const InlineSelectControl = styled(SelectControl)` width: 180px; `; const Separator = styled('span')` margin-right: ${space(1)}; padding-top: ${space(0.5)}; padding-bottom: ${space(0.5)}; `; const RuleRow = styled('div')` display: flex; align-items: center; padding: ${space(1)}; `; const RuleRowContainer = styled('div')<{incompatible?: boolean}>` background-color: ${p => p.theme.backgroundSecondary}; border-radius: ${p => p.theme.borderRadius}; border: 1px ${p => p.theme.innerBorder} solid; border-color: ${p => (p.incompatible ? p.theme.red200 : 'none')}; `; const Rule = styled('div')` display: flex; align-items: center; flex: 1; flex-wrap: wrap; `; const DeleteButton = styled(Button)` flex-shrink: 0; `; const MarginlessAlert = styled(Alert)` border-top-left-radius: 0; border-top-right-radius: 0; border-width: 0; border-top: 1px ${p => p.theme.innerBorder} solid; margin: 0; padding: ${space(1)} ${space(1)}; `;