Skip to content

Commit 06a3ce4

Browse files
authored
feat(aci): Send project slug with test fire action request (#113127)
1 parent f5e6ec5 commit 06a3ce4

File tree

5 files changed

+307
-186
lines changed

5 files changed

+307
-186
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Button} from '@sentry/scraps/button';
4+
import {Container, Flex} from '@sentry/scraps/layout';
5+
6+
import {useFormField} from 'sentry/components/workflowEngine/form/useFormField';
7+
import {ConditionBadge} from 'sentry/components/workflowEngine/ui/conditionBadge';
8+
import {IconDelete, IconMail} from 'sentry/icons';
9+
import {t, tct} from 'sentry/locale';
10+
import type {SelectValue} from 'sentry/types/core';
11+
import type {Action} from 'sentry/types/workflowEngine/actions';
12+
import {
13+
DataConditionGroupLogicType,
14+
DataConditionHandlerGroupType,
15+
type DataConditionGroup,
16+
} from 'sentry/types/workflowEngine/dataConditions';
17+
import type {RequestError} from 'sentry/utils/requestError/requestError';
18+
import {useProjects} from 'sentry/utils/useProjects';
19+
import {FILTER_MATCH_OPTIONS} from 'sentry/views/automations/components/actionFilters/constants';
20+
import {ActionNodeList} from 'sentry/views/automations/components/actionNodeList';
21+
import {useAutomationBuilderContext} from 'sentry/views/automations/components/automationBuilderContext';
22+
import {useAutomationBuilderErrorContext} from 'sentry/views/automations/components/automationBuilderErrorContext';
23+
import {
24+
stripActionFields,
25+
validateActions,
26+
} from 'sentry/views/automations/components/automationFormData';
27+
import {DataConditionNodeList} from 'sentry/views/automations/components/dataConditionNodeList';
28+
import {
29+
EmbeddedSelectField,
30+
Step,
31+
StepLead,
32+
} from 'sentry/views/automations/components/stepComponents';
33+
import {useSendTestNotification as useSendTestNotificationMutation} from 'sentry/views/automations/hooks';
34+
import {useConnectedDetectors} from 'sentry/views/automations/hooks/useConnectedDetectors';
35+
36+
// We want the test notification to use a project that makes sense for alert config,
37+
// so this selects the first project that is connected, and that the user has access to.
38+
// If no project meets these criteria, we send nothing and the endpoint will default to a random project.
39+
function useTestNotificationProjectSlug(): string | undefined {
40+
const formProjectIds = useFormField<string[]>('projectIds');
41+
const {connectedDetectors} = useConnectedDetectors();
42+
const {projects} = useProjects();
43+
44+
const selectedProjectIds = new Set([
45+
...(formProjectIds ?? []),
46+
...connectedDetectors.map(d => d.projectId),
47+
]);
48+
49+
return projects.find(p => selectedProjectIds.has(p.id))?.slug;
50+
}
51+
52+
function useSendTestNotification(actionFilterActions: Action[]) {
53+
const {setErrors} = useAutomationBuilderErrorContext();
54+
const projectSlug = useTestNotificationProjectSlug();
55+
56+
const {mutate, isPending} = useSendTestNotificationMutation({
57+
onError: (error: RequestError) => {
58+
setErrors(prev => ({
59+
...prev,
60+
...error?.responseJSON,
61+
}));
62+
},
63+
});
64+
65+
const sendTestNotification = () => {
66+
const actionErrors = validateActions({actions: actionFilterActions});
67+
setErrors(prev => ({...prev, ...actionErrors}));
68+
69+
if (Object.keys(actionErrors).length === 0) {
70+
mutate({
71+
actions: actionFilterActions.map(action => stripActionFields(action)),
72+
projectSlug,
73+
});
74+
}
75+
};
76+
77+
return {sendTestNotification, isPending};
78+
}
79+
80+
interface ActionFilterBlockProps {
81+
actionFilter: DataConditionGroup;
82+
}
83+
84+
export function ActionFilterBlock({actionFilter}: ActionFilterBlockProps) {
85+
const {state, actions} = useAutomationBuilderContext();
86+
const actionFilterActions = actionFilter.actions || [];
87+
const {sendTestNotification, isPending} = useSendTestNotification(actionFilterActions);
88+
const numActionFilters = state.actionFilters.length;
89+
90+
return (
91+
<IfThenWrapper>
92+
<Step>
93+
<Flex direction="column" gap="md">
94+
<StepLead data-test-id="action-filter-logic-type">
95+
{tct('[if: If] [selector] of these filters match', {
96+
if: <ConditionBadge />,
97+
selector: (
98+
<Container width="80px">
99+
<EmbeddedSelectField
100+
styles={{
101+
control: (provided: any) => ({
102+
...provided,
103+
minHeight: '21px',
104+
height: '21px',
105+
}),
106+
}}
107+
isSearchable={false}
108+
isClearable={false}
109+
name={`actionFilters.${actionFilter.id}.logicType`}
110+
options={FILTER_MATCH_OPTIONS}
111+
size="xs"
112+
value={
113+
FILTER_MATCH_OPTIONS.find(
114+
choice =>
115+
choice.value === actionFilter.logicType ||
116+
choice.alias === actionFilter.logicType
117+
)?.value || actionFilter.logicType
118+
}
119+
onChange={(option: SelectValue<DataConditionGroupLogicType>) =>
120+
actions.updateIfLogicType(actionFilter.id, option.value)
121+
}
122+
/>
123+
</Container>
124+
),
125+
})}
126+
</StepLead>
127+
{numActionFilters > 1 && (
128+
<DeleteButton
129+
aria-label={t('Delete If/Then Block')}
130+
size="sm"
131+
icon={<IconDelete />}
132+
priority="transparent"
133+
onClick={() => actions.removeIf(actionFilter.id)}
134+
className="delete-condition-group"
135+
/>
136+
)}
137+
<DataConditionNodeList
138+
handlerGroup={DataConditionHandlerGroupType.ACTION_FILTER}
139+
label={t('Add filter')}
140+
placeholder={t('Any event')}
141+
groupId={actionFilter.id}
142+
conditions={actionFilter?.conditions || []}
143+
onAddRow={type => actions.addIfCondition(actionFilter.id, type)}
144+
onDeleteRow={id => actions.removeIfCondition(actionFilter.id, id)}
145+
updateCondition={(id, params) =>
146+
actions.updateIfCondition(actionFilter.id, id, params)
147+
}
148+
/>
149+
</Flex>
150+
</Step>
151+
<Step>
152+
<StepLead>
153+
{tct('[then:Then] perform these actions', {
154+
then: <ConditionBadge />,
155+
})}
156+
</StepLead>
157+
<ActionNodeList
158+
placeholder={t('Select an action')}
159+
conditionGroupId={actionFilter.id}
160+
actions={actionFilter?.actions || []}
161+
onAddRow={handler => actions.addIfAction(actionFilter.id, handler)}
162+
onDeleteRow={id => actions.removeIfAction(actionFilter.id, id)}
163+
updateAction={(id, data) => actions.updateIfAction(actionFilter.id, id, data)}
164+
/>
165+
</Step>
166+
<span>
167+
<Button
168+
icon={<IconMail />}
169+
onClick={sendTestNotification}
170+
disabled={!actionFilter.actions?.length || isPending}
171+
>
172+
{t('Send Test Notification')}
173+
</Button>
174+
</span>
175+
</IfThenWrapper>
176+
);
177+
}
178+
179+
const IfThenWrapper = styled(Flex)`
180+
position: relative;
181+
flex-direction: column;
182+
gap: ${p => p.theme.space.md};
183+
border: 1px solid ${p => p.theme.tokens.border.primary};
184+
border-radius: ${p => p.theme.radius.md};
185+
padding: ${p => p.theme.space.lg};
186+
margin-top: ${p => p.theme.space.md};
187+
188+
/* Only hide delete button when hover is supported */
189+
@media (hover: hover) {
190+
&:not(:hover):not(:focus-within) {
191+
.delete-condition-group {
192+
${p => p.theme.visuallyHidden}
193+
}
194+
}
195+
}
196+
`;
197+
198+
const DeleteButton = styled(Button)`
199+
position: absolute;
200+
top: ${p => p.theme.space.sm};
201+
right: ${p => p.theme.space.sm};
202+
`;

0 commit comments

Comments
 (0)