From 0532e5d0c857a070d2932d5776b8cbaf52b8b1a2 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Thu, 22 May 2025 16:53:31 -0700 Subject: [PATCH 1/3] ref(aci): ref actionNodeContext to include action handler --- static/app/types/workflowEngine/actions.tsx | 31 +++++++- .../automations/components/actionNodeList.tsx | 70 +++++++++++++++---- .../automations/components/actionNodes.tsx | 29 ++++++-- .../components/actions/integrationField.tsx | 4 +- .../components/actions/sentryApp.tsx | 13 ++++ .../actions/sentryAppSettingsButton.tsx | 12 ++++ .../components/actions/webhook.tsx | 30 ++++++++ .../components/automationBuilder.tsx | 4 +- .../components/automationBuilderContext.tsx | 23 +++--- static/app/views/automations/hooks/index.tsx | 10 +++ 10 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 static/app/views/automations/components/actions/sentryApp.tsx create mode 100644 static/app/views/automations/components/actions/sentryAppSettingsButton.tsx create mode 100644 static/app/views/automations/components/actions/webhook.tsx diff --git a/static/app/types/workflowEngine/actions.tsx b/static/app/types/workflowEngine/actions.tsx index 48903142daf683..3149898b787035 100644 --- a/static/app/types/workflowEngine/actions.tsx +++ b/static/app/types/workflowEngine/actions.tsx @@ -15,13 +15,28 @@ export enum ActionType { GITHUB_ENTERPRISE = 'github_enterprise', JIRA = 'jira', JIRA_SERVER = 'jira_server', - AZURE_DEVOPS = 'azure_devops', + AZURE_DEVOPS = 'vsts', EMAIL = 'email', SENTRY_APP = 'sentry_app', PLUGIN = 'plugin', WEBHOOK = 'webhook', } +export enum ActionGroup { + NOTIFICATION = 'notification', + TICKET_CREATION = 'ticket_creation', + OTHER = 'other', +} + +export interface ActionHandler { + configSchema: Record; + dataSchema: Record; + handlerGroup: ActionGroup; + type: ActionType; + integrations?: Integration[]; + sentryApp?: SentryAppContext; + services?: PluginService[]; +} export interface Integration { id: string; name: string; @@ -30,3 +45,17 @@ export interface Integration { name: string; }>; } + +export interface SentryAppContext { + id: string; + installationId: string; + name: string; + status: number; + settings?: Record; + title?: string; +} + +export interface PluginService { + name: string; + slug: string; +} diff --git a/static/app/views/automations/components/actionNodeList.tsx b/static/app/views/automations/components/actionNodeList.tsx index 35f72e13ff1fb7..62f283d27711e6 100644 --- a/static/app/views/automations/components/actionNodeList.tsx +++ b/static/app/views/automations/components/actionNodeList.tsx @@ -1,20 +1,26 @@ -import {Fragment} from 'react'; +import {Fragment, useMemo, useRef} from 'react'; import styled from '@emotion/styled'; +import {uuid4} from '@sentry/core'; import {Select} from 'sentry/components/core/select'; -import type {Action, ActionType, Integration} from 'sentry/types/workflowEngine/actions'; +import {t} from 'sentry/locale'; +import { + type Action, + ActionGroup, + type ActionHandler, +} from 'sentry/types/workflowEngine/actions'; import { ActionNodeContext, actionNodesMap, useActionNodeContext, } from 'sentry/views/automations/components/actionNodes'; import AutomationBuilderRow from 'sentry/views/automations/components/automationBuilderRow'; +import {useAvailableActionsQuery} from 'sentry/views/automations/hooks'; interface ActionNodeListProps { actions: Action[]; - availableActions: Array<{type: ActionType; integrations?: Integration[]}>; group: string; - onAddRow: (type: ActionType) => void; + onAddRow: (actionId: string, actionHandler: ActionHandler) => void; onDeleteRow: (id: string) => void; placeholder: string; updateAction: (id: string, data: Record) => void; @@ -24,14 +30,51 @@ export default function ActionNodeList({ group, placeholder, actions, - availableActions, onAddRow, onDeleteRow, updateAction, }: ActionNodeListProps) { - const options = Array.from(actionNodesMap) - .filter(([value]) => availableActions.some(action => action.type === value)) - .map(([value, {label}]) => ({value, label})); + const {data: availableActions = []} = useAvailableActionsQuery(); + const actionHandlerMapRef = useRef(new Map()); + + const options = useMemo(() => { + const typeOptionsMap = new Map< + ActionGroup, + Array<{label: string; value: ActionHandler}> + >(); + + availableActions.forEach(action => { + const existingOptions = typeOptionsMap.get(action.handlerGroup) || []; + const label = + actionNodesMap.get(action.type)?.label || action.sentryApp?.name || action.type; + + typeOptionsMap.set(action.handlerGroup, [ + ...existingOptions, + { + value: action, + label, + }, + ]); + }); + + return [ + { + key: ActionGroup.NOTIFICATION, + label: t('Notifications'), + options: typeOptionsMap.get(ActionGroup.NOTIFICATION) || [], + }, + { + key: ActionGroup.TICKET_CREATION, + label: t('Ticket Creation'), + options: typeOptionsMap.get(ActionGroup.TICKET_CREATION) || [], + }, + { + key: ActionGroup.OTHER, + label: t('Other Integrations'), + options: typeOptionsMap.get(ActionGroup.OTHER) || [], + }, + ]; + }, [availableActions]); return ( @@ -47,8 +90,7 @@ export default function ActionNodeList({ action, actionId: `${group}.action.${action.id}`, onUpdate: newAction => updateAction(action.id, newAction), - integrations: availableActions.find(a => a.type === action.type) - ?.integrations, + handler: actionHandlerMapRef.current.get(action.id), }} > @@ -58,7 +100,9 @@ export default function ActionNodeList({ { - onAddRow(obj.value); + const actionId = uuid4(); + onAddRow(actionId, obj.value); + actionHandlerMapRef.current.set(actionId, obj.value); }} placeholder={placeholder} value={null} @@ -70,7 +114,9 @@ export default function ActionNodeList({ function Node() { const {action} = useActionNodeContext(); const node = actionNodesMap.get(action.type); - return node?.action; + + const component = node?.action; + return component ? component : node?.label; } const StyledSelectControl = styled(Select)` diff --git a/static/app/views/automations/components/actionNodes.tsx b/static/app/views/automations/components/actionNodes.tsx index c2041caef031e5..cf672bc0478e7d 100644 --- a/static/app/views/automations/components/actionNodes.tsx +++ b/static/app/views/automations/components/actionNodes.tsx @@ -1,7 +1,7 @@ import {createContext, useContext} from 'react'; import {t} from 'sentry/locale'; -import type {Action, Integration} from 'sentry/types/workflowEngine/actions'; +import type {Action, ActionHandler} from 'sentry/types/workflowEngine/actions'; import {ActionType} from 'sentry/types/workflowEngine/actions'; import {AzureDevOpsNode} from 'sentry/views/automations/components/actions/azureDevOps'; import {DiscordNode} from 'sentry/views/automations/components/actions/discord'; @@ -13,13 +13,15 @@ import {JiraServerNode} from 'sentry/views/automations/components/actions/jiraSe import {MSTeamsNode} from 'sentry/views/automations/components/actions/msTeams'; import {OpsgenieNode} from 'sentry/views/automations/components/actions/opsgenie'; import {PagerdutyNode} from 'sentry/views/automations/components/actions/pagerduty'; +import {SentryAppNode} from 'sentry/views/automations/components/actions/sentryApp'; import {SlackNode} from 'sentry/views/automations/components/actions/slack'; +import {WebhookNode} from 'sentry/views/automations/components/actions/webhook'; interface ActionNodeProps { action: Action; actionId: string; + handler: ActionHandler; onUpdate: (condition: Record) => void; - integrations?: Integration[]; } export const ActionNodeContext = createContext(null); @@ -33,13 +35,13 @@ export function useActionNodeContext(): ActionNodeProps { } type ActionNode = { - action: React.ReactNode; - label: string; + action?: React.ReactNode; + label?: string; }; export const actionNodesMap = new Map([ [ActionType.AZURE_DEVOPS, {label: t('Azure DevOps'), action: }], - [ActionType.EMAIL, {label: t('Email'), action: }], + [ActionType.EMAIL, {label: t('Notify on preferred channel'), action: }], [ ActionType.DISCORD, { @@ -69,6 +71,19 @@ export const actionNodesMap = new Map([ action: , }, ], + [ + ActionType.PLUGIN, + { + label: t('Legacy integrations'), + action: t('Send a notification (for all legacy integrations)'), + }, + ], + [ + ActionType.SENTRY_APP, + { + action: , + }, + ], [ ActionType.SLACK, { @@ -76,4 +91,8 @@ export const actionNodesMap = new Map([ action: , }, ], + [ + ActionType.WEBHOOK, + {label: t('Send a notification via an integration'), action: }, + ], ]); diff --git a/static/app/views/automations/components/actions/integrationField.tsx b/static/app/views/automations/components/actions/integrationField.tsx index 3133006b29f3c7..8cfa8403200878 100644 --- a/static/app/views/automations/components/actions/integrationField.tsx +++ b/static/app/views/automations/components/actions/integrationField.tsx @@ -2,7 +2,9 @@ import AutomationBuilderSelectField from 'sentry/components/workflowEngine/form/ import {useActionNodeContext} from 'sentry/views/automations/components/actionNodes'; export function IntegrationField() { - const {action, actionId, onUpdate, integrations} = useActionNodeContext(); + const {action, actionId, onUpdate, handler} = useActionNodeContext(); + const integrations = handler?.integrations; + return ( , + }); +} diff --git a/static/app/views/automations/components/actions/sentryAppSettingsButton.tsx b/static/app/views/automations/components/actions/sentryAppSettingsButton.tsx new file mode 100644 index 00000000000000..b8bd2937adef75 --- /dev/null +++ b/static/app/views/automations/components/actions/sentryAppSettingsButton.tsx @@ -0,0 +1,12 @@ +import {Button} from 'sentry/components/core/button'; +import {IconSettings} from 'sentry/icons'; +import {t} from 'sentry/locale'; + +// TODO(miahsu): Implement the action settings button/modal +export function SentryAppActionSettingsButton() { + return ( + + ); +} diff --git a/static/app/views/automations/components/actions/webhook.tsx b/static/app/views/automations/components/actions/webhook.tsx new file mode 100644 index 00000000000000..b4ea8d8fd859d0 --- /dev/null +++ b/static/app/views/automations/components/actions/webhook.tsx @@ -0,0 +1,30 @@ +import AutomationBuilderSelectField from 'sentry/components/workflowEngine/form/automationBuilderSelectField'; +import {tct} from 'sentry/locale'; +import {useActionNodeContext} from 'sentry/views/automations/components/actionNodes'; + +export function WebhookNode() { + return tct('Send a notification via [services]', { + services: , + }); +} + +export function ServicesField() { + const {action, actionId, onUpdate, handler} = useActionNodeContext(); + const services = handler?.services; + + return ( + ({ + label: service.name, + value: service.slug, + }))} + onChange={(value: string) => { + onUpdate({ + targetIdentifier: value, + }); + }} + /> + ); +} diff --git a/static/app/views/automations/components/automationBuilder.tsx b/static/app/views/automations/components/automationBuilder.tsx index 48daf12a86f0d5..adf4e1bf0f901c 100644 --- a/static/app/views/automations/components/automationBuilder.tsx +++ b/static/app/views/automations/components/automationBuilder.tsx @@ -178,12 +178,10 @@ function ActionFilterBlock({actionFilter}: ActionFilterBlockProps) { {/* TODO: add actions dropdown here */} actions.addIfAction(actionFilter.id, type)} + onAddRow={(id, type) => actions.addIfAction(actionFilter.id, id, type)} onDeleteRow={id => actions.removeIfAction(actionFilter.id, id)} updateAction={(id, data) => actions.updateIfAction(actionFilter.id, id, data)} /> diff --git a/static/app/views/automations/components/automationBuilderContext.tsx b/static/app/views/automations/components/automationBuilderContext.tsx index e3132dbf584cee..7dffd016ea9bd3 100644 --- a/static/app/views/automations/components/automationBuilderContext.tsx +++ b/static/app/views/automations/components/automationBuilderContext.tsx @@ -1,7 +1,7 @@ import {createContext, type Reducer, useCallback, useContext, useReducer} from 'react'; import {uuid4} from '@sentry/core'; -import type {ActionType} from 'sentry/types/workflowEngine/actions'; +import type {ActionHandler} from 'sentry/types/workflowEngine/actions'; import { type DataConditionGroup, DataConditionGroupLogicType, @@ -100,8 +100,8 @@ export function useAutomationBuilderReducer() { [dispatch] ), addIfAction: useCallback( - (groupId: string, actionType: ActionType) => - dispatch({type: 'ADD_IF_ACTION', groupId, actionType}), + (groupId: string, actionId: string, actionHandler: ActionHandler) => + dispatch({type: 'ADD_IF_ACTION', groupId, actionId, actionHandler}), [dispatch] ), removeIfAction: useCallback( @@ -135,7 +135,7 @@ interface AutomationBuilderState { // 2. The AutomationActions interface interface AutomationActions { addIf: () => void; - addIfAction: (groupId: string, actionType: ActionType) => void; + addIfAction: (groupId: string, actionId: string, actionHandler: ActionHandler) => void; addIfCondition: (groupId: string, conditionType: DataConditionType) => void; addWhenCondition: (conditionType: DataConditionType) => void; removeIf: (groupId: string) => void; @@ -245,7 +245,8 @@ type UpdateIfConditionAction = { }; type AddIfActionAction = { - actionType: ActionType; + actionHandler: ActionHandler; + actionId: string; groupId: string; type: 'ADD_IF_ACTION'; }; @@ -468,7 +469,7 @@ function addIfAction( state: AutomationBuilderState, action: AddIfActionAction ): AutomationBuilderState { - const {groupId, actionType} = action; + const {groupId, actionId, actionHandler} = action; return { ...state, actionFilters: state.actionFilters.map(group => { @@ -480,9 +481,13 @@ function addIfAction( actions: [ ...(group.actions ?? []), { - id: uuid4(), - type: actionType, - data: {}, + id: actionId, + type: actionHandler.type, + data: { + ...(actionHandler.sentryApp + ? {targetIdentifier: actionHandler.sentryApp.id} + : {}), + }, }, ], }; diff --git a/static/app/views/automations/hooks/index.tsx b/static/app/views/automations/hooks/index.tsx index a27818e8ab17ca..1747b05b67d33a 100644 --- a/static/app/views/automations/hooks/index.tsx +++ b/static/app/views/automations/hooks/index.tsx @@ -1,3 +1,4 @@ +import type {ActionHandler} from 'sentry/types/workflowEngine/actions'; import type {Automation} from 'sentry/types/workflowEngine/automations'; import {useApiQuery} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; @@ -19,3 +20,12 @@ export const makeAutomationQueryKey = ( orgSlug: string, automationId = '' ): [url: string] => [`/organizations/${orgSlug}/workflows/${automationId}/`]; + +export function useAvailableActionsQuery() { + const {slug} = useOrganization(); + + return useApiQuery([`/organizations/${slug}/available-actions/`], { + staleTime: Infinity, + retry: false, + }); +} From 4cd772c82d4ce29aa727518b9ebb6cb1027ac1c8 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Thu, 22 May 2025 17:03:49 -0700 Subject: [PATCH 2/3] fix serviceField --- .../app/views/automations/components/actions/serviceField.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/automations/components/actions/serviceField.tsx b/static/app/views/automations/components/actions/serviceField.tsx index 515a49bae8a2b6..4886d98e999f43 100644 --- a/static/app/views/automations/components/actions/serviceField.tsx +++ b/static/app/views/automations/components/actions/serviceField.tsx @@ -2,9 +2,9 @@ import AutomationBuilderSelectField from 'sentry/components/workflowEngine/form/ import {useActionNodeContext} from 'sentry/views/automations/components/actionNodes'; export function ServiceField() { - const {action, actionId, onUpdate, integrations} = useActionNodeContext(); + const {action, actionId, onUpdate, handler} = useActionNodeContext(); const integrationId = action.integrationId; - const integration = integrations?.find(i => i.id === integrationId); + const integration = handler.integrations?.find(i => i.id === integrationId); if (!integration || !integrationId) { return null; From e2be21b7558458d72b7e1cc047ecaaa5f856a877 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Thu, 22 May 2025 17:09:49 -0700 Subject: [PATCH 3/3] flex --- .../components/workflowEngine/form/automationBuilderRowLine.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/components/workflowEngine/form/automationBuilderRowLine.tsx b/static/app/components/workflowEngine/form/automationBuilderRowLine.tsx index 24fa1d76f1428c..ea6b2401900207 100644 --- a/static/app/components/workflowEngine/form/automationBuilderRowLine.tsx +++ b/static/app/components/workflowEngine/form/automationBuilderRowLine.tsx @@ -7,6 +7,7 @@ export const RowLine = styled('div')` align-items: center; gap: ${space(1)}; flex-wrap: wrap; + flex: 1; `; export const OptionalRowLine = styled(RowLine)`