Files
2024-02-15 17:01:50 +00:00

313 lines
8.5 KiB
TypeScript

import {Component, Fragment} from 'react';
import styled from '@emotion/styled';
import SelectControl from 'sentry/components/forms/controls/selectControl';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, Project} from 'sentry/types';
import type {
IssueAlertConfiguration,
IssueAlertGenericConditionConfig,
IssueAlertRuleAction,
IssueAlertRuleActionTemplate,
IssueAlertRuleCondition,
IssueAlertRuleConditionTemplate,
} from 'sentry/types/alerts';
import {IssueAlertActionType, IssueAlertConditionType} from 'sentry/types/alerts';
import {
CHANGE_ALERT_CONDITION_IDS,
COMPARISON_INTERVAL_CHOICES,
COMPARISON_TYPE_CHOICE_VALUES,
COMPARISON_TYPE_CHOICES,
} from 'sentry/views/alerts/utils/constants';
import {AlertRuleComparisonType} from '../metric/types';
import RuleNode from './ruleNode';
type Props = {
disabled: boolean;
error: React.ReactNode;
/**
* actions/conditions that have been added to the rule
*/
items: IssueAlertRuleAction[] | IssueAlertRuleCondition[];
/**
* All available actions or conditions
*/
nodes: IssueAlertConfiguration[keyof IssueAlertConfiguration] | null;
onAddRow: (
value: IssueAlertRuleActionTemplate | IssueAlertRuleConditionTemplate
) => void;
onDeleteRow: (ruleIndex: number) => void;
onPropertyChange: (ruleIndex: number, prop: string, val: string) => void;
onResetRow: (ruleIndex: number, name: string, value: string) => void;
organization: Organization;
/**
* Placeholder for select control
*/
placeholder: string;
project: Project;
incompatibleBanner?: number | null;
incompatibleRules?: number[] | null;
selectType?: 'grouped';
};
const createSelectOptions = (
actions: IssueAlertRuleActionTemplate[]
): Array<{
label: React.ReactNode;
value: IssueAlertRuleActionTemplate;
}> => {
return actions.map(node => {
if (node.id === IssueAlertActionType.NOTIFY_EMAIL) {
const label = t('Suggested Assignees, Team, or Member');
return {
value: node,
label,
};
}
if (node.id === IssueAlertConditionType.REAPPEARED_EVENT) {
const label = t('The issue changes state from archived to escalating');
return {
value: node,
label,
};
}
return {
value: node,
label: node.prompt ?? node.label,
};
});
};
const groupLabels = {
notify: t('Send notification to\u{2026}'),
notifyIntegration: t('Notify integration\u{2026}'),
ticket: t('Create new\u{2026}'),
change: t('Issue state change'),
frequency: t('Issue frequency'),
};
/**
* Group options by category
*/
const groupSelectOptions = (actions: IssueAlertRuleActionTemplate[]) => {
const grouped = actions.reduce<
Record<
keyof typeof groupLabels,
IssueAlertRuleActionTemplate[] | IssueAlertRuleConditionTemplate[]
>
>(
(acc, curr) => {
if (curr.actionType === 'ticket') {
acc.ticket.push(curr);
} else if (curr.id.includes('event_frequency')) {
acc.frequency.push(curr);
} else if (
curr.id.includes('sentry.rules.conditions') &&
!curr.id.includes('event_frequency')
) {
acc.change.push(curr);
} else if (curr.id.includes('sentry.integrations')) {
acc.notifyIntegration.push(curr);
} else if (curr.id.includes('notify_event')) {
acc.notifyIntegration.push(curr);
} else {
acc.notify.push(curr);
}
return acc;
},
{
notify: [],
notifyIntegration: [],
ticket: [],
change: [],
frequency: [],
}
);
return Object.entries(grouped)
.filter(([_, values]) => values.length)
.map(([key, values]) => {
return {
label: groupLabels[key],
options: createSelectOptions(values),
};
});
};
class RuleNodeList extends Component<Props> {
componentWillUnmount() {
window.clearTimeout(this.propertyChangeTimeout);
}
propertyChangeTimeout: number | undefined = undefined;
getNode = (
template: IssueAlertRuleAction | IssueAlertRuleCondition,
itemIdx: number
): IssueAlertConfiguration[keyof IssueAlertConfiguration][number] | null => {
const {nodes, items, organization, onPropertyChange} = this.props;
const node = nodes?.find(n => {
if ('sentryAppInstallationUuid' in n) {
// Match more than just the id for sentryApp actions, they share the same id
return (
n.id === template.id &&
n.sentryAppInstallationUuid === template.sentryAppInstallationUuid
);
}
return n.id === template.id;
});
if (!node) {
return null;
}
if (
!organization.features.includes('change-alerts') ||
!CHANGE_ALERT_CONDITION_IDS.includes(node.id)
) {
return node;
}
const item = items[itemIdx];
let changeAlertNode: IssueAlertGenericConditionConfig = {
...(node as IssueAlertGenericConditionConfig),
label: node.label.replace('...', ' {comparisonType}'),
formFields: {
...(node.formFields as IssueAlertGenericConditionConfig['formFields']),
comparisonType: {
type: 'choice',
choices: COMPARISON_TYPE_CHOICES,
// give an initial value from not among choices so selector starts with none selected
initial: 'select',
},
},
};
// item.comparison type isn't backfilled and is missing for old alert rules
// this is a problem when an old alert is being edited, need to initialize it
if (!item.comparisonType && item.value && item.name) {
item.comparisonType = item.comparisonInterval === undefined ? 'count' : 'percent';
}
if (item.comparisonType) {
changeAlertNode = {
...changeAlertNode,
label: changeAlertNode.label.replace(
'{comparisonType}',
COMPARISON_TYPE_CHOICE_VALUES[item.comparisonType]
),
};
if (item.comparisonType === AlertRuleComparisonType.PERCENT) {
if (!item.comparisonInterval) {
// comparisonInterval value in IssueRuleEditor state
// is undefined even if initial value is defined
// can't directly call onPropertyChange, because
// getNode is called during render
window.clearTimeout(this.propertyChangeTimeout);
this.propertyChangeTimeout = window.setTimeout(() =>
onPropertyChange(itemIdx, 'comparisonInterval', '1w')
);
}
changeAlertNode = {
...changeAlertNode,
formFields: {
...changeAlertNode.formFields,
comparisonInterval: {
type: 'choice',
choices: COMPARISON_INTERVAL_CHOICES,
initial: '1w',
},
},
};
}
}
return changeAlertNode;
};
render() {
const {
onAddRow,
onResetRow,
onDeleteRow,
onPropertyChange,
nodes,
placeholder,
items,
organization,
project,
disabled,
error,
selectType,
incompatibleRules,
incompatibleBanner,
} = this.props;
const enabledNodes = nodes ? nodes.filter(({enabled}) => enabled) : [];
const options =
selectType === 'grouped'
? groupSelectOptions(enabledNodes)
: createSelectOptions(enabledNodes);
return (
<Fragment>
<RuleNodes>
{error}
{items.map(
(item: IssueAlertRuleAction | IssueAlertRuleCondition, idx: number) => (
<RuleNode
key={idx}
index={idx}
node={this.getNode(item, idx)}
onDelete={onDeleteRow}
onPropertyChange={onPropertyChange}
onReset={onResetRow}
data={item}
organization={organization}
project={project}
disabled={disabled}
incompatibleRule={incompatibleRules?.includes(idx)}
incompatibleBanner={incompatibleBanner === idx}
/>
)
)}
</RuleNodes>
<StyledSelectControl
placeholder={placeholder}
value={null}
onChange={obj => {
onAddRow(obj.value);
}}
options={options}
disabled={disabled}
/>
</Fragment>
);
}
}
export default RuleNodeList;
const StyledSelectControl = styled(SelectControl)`
width: 100%;
`;
const RuleNodes = styled('div')`
display: grid;
margin-bottom: ${space(1)};
gap: ${space(1)};
@media (max-width: ${p => p.theme.breakpoints.medium}) {
grid-auto-flow: row;
}
`;