Skip to content

Commit b589ff9

Browse files
committed
feat(aci): Send project slug with test fire action request
The test notification endpoint accepts an optional projectSlug to control which project is shown in the notification. This picks a project from the connected monitors that the user has access to, so the test notification displays a relevant project instead of an arbitrary one. Also extracts ActionFilterBlock into its own file for clarity.
1 parent f483b2b commit b589ff9

File tree

4 files changed

+293
-167
lines changed

4 files changed

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

0 commit comments

Comments
 (0)