|
| 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