From f121c94d4add54f2268203592907c2cc64f5fcd8 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 8 Apr 2025 21:01:22 +0200 Subject: [PATCH] 701 workflow improve webhook triggers (#11455) as title Nota bene: I did not filter execution by http method. A POST webhook trigger can be triggered by a GET request for more flexibility. Tell me if you think it is a mistake https://github.com/user-attachments/assets/1833cbea-51a8-4772-bcd8-088d6a087e79 --- .../components/FormRawJsonFieldInput.tsx | 7 ++ .../ui/input/components/InputErrorHelper.tsx | 2 +- .../validation-schemas/workflowSchema.ts | 16 ++- .../WorkflowEditTriggerWebhookForm.tsx | 114 +++++++++++++++++- .../WebhookTriggerAuthenticationOptions.ts | 19 +++ .../WebhookTriggerHttpMethodOptions.ts | 19 +++ .../hooks/useUpdateWorkflowVersionTrigger.ts | 25 ++-- .../getWebhookTriggerDefaultSettings.test.ts | 39 ++++++ .../utils/getTriggerDefaultDefinition.ts | 2 + .../utils/getWebhookTriggerDefaultSettings.ts | 34 ++++++ .../workflow-trigger.controller.ts | 26 +++- .../types/workflow-trigger.type.ts | 10 ++ .../display/icon/components/TablerIcons.ts | 2 + packages/twenty-ui/src/display/index.ts | 2 + 14 files changed, 297 insertions(+), 20 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx index 14f227e11..588d210c4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx @@ -8,11 +8,14 @@ import { InputLabel } from '@/ui/input/components/InputLabel'; import { useId } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; +import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper'; type FormRawJsonFieldInputProps = { label?: string; + error?: string; defaultValue: string | null | undefined; onChange: (value: string | null) => void; + onBlur?: () => void; readonly?: boolean; VariablePicker?: VariablePickerComponent; placeholder?: string; @@ -20,9 +23,11 @@ type FormRawJsonFieldInputProps = { export const FormRawJsonFieldInput = ({ label, + error, defaultValue, placeholder, onChange, + onBlur, readonly, VariablePicker, }: FormRawJsonFieldInputProps) => { @@ -68,6 +73,7 @@ export const FormRawJsonFieldInput = ({ @@ -80,6 +86,7 @@ export const FormRawJsonFieldInput = ({ /> )} + {error} ); }; diff --git a/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx b/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx index 9d20e6962..5f3d46909 100644 --- a/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx @@ -5,7 +5,7 @@ const StyledInputErrorHelper = styled.div` color: ${({ theme }) => theme.color.red}; font-size: ${({ theme }) => theme.font.size.xs}; position: absolute; - top: calc(100% + ${({ theme }) => theme.spacing(0.25)}); + margin-top: ${({ theme }) => theme.spacing(0.25)}; `; export const InputErrorHelper = ({ diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index 3215ca6d3..3496f7ae7 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -209,9 +209,19 @@ export const workflowCronTriggerSchema = baseTriggerSchema.extend({ export const workflowWebhookTriggerSchema = baseTriggerSchema.extend({ type: z.literal('WEBHOOK'), - settings: z.object({ - outputSchema: z.object({}).passthrough(), - }), + settings: z.discriminatedUnion('httpMethod', [ + z.object({ + outputSchema: z.object({}).passthrough(), + httpMethod: z.literal('GET'), + authentication: z.literal('API_KEY').nullable(), + }), + z.object({ + outputSchema: z.object({}).passthrough(), + httpMethod: z.literal('POST'), + expectedBody: z.object({}).passthrough(), + authentication: z.literal('API_KEY').nullable(), + }), + ]), }); // Combined trigger schema diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx index e818579c4..9436d8e5f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx @@ -14,6 +14,13 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { isDefined } from 'twenty-shared/utils'; import { useIcons, IconCopy } from 'twenty-ui/display'; +import { Select } from '@/ui/input/components/Select'; +import { WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions'; +import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings'; +import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions'; +import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput'; +import { useState } from 'react'; +import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema'; type WorkflowEditTriggerWebhookFormProps = { trigger: WorkflowWebhookTrigger; @@ -24,9 +31,17 @@ type WorkflowEditTriggerWebhookFormProps = { } | { readonly?: false; - onTriggerUpdate: (trigger: WorkflowWebhookTrigger) => void; + onTriggerUpdate: ( + trigger: WorkflowWebhookTrigger, + options?: { computeOutputSchema: boolean }, + ) => void; }; }; + +type FormErrorMessages = { + expectedBody?: string | undefined; +}; + export const WorkflowEditTriggerWebhookForm = ({ trigger, triggerOptions, @@ -34,10 +49,16 @@ export const WorkflowEditTriggerWebhookForm = ({ const { enqueueSnackBar } = useSnackBar(); const theme = useTheme(); const { t } = useLingui(); + const [errorMessages, setErrorMessages] = useState({}); + const [errorMessagesVisible, setErrorMessagesVisible] = useState(false); const { getIcon } = useIcons(); const workflowId = useRecoilValue(workflowIdState); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const onBlur = () => { + setErrorMessagesVisible(true); + }; + const headerTitle = isDefined(trigger.name) ? trigger.name : 'Webhook'; const headerIcon = getTriggerIcon({ @@ -93,6 +114,97 @@ export const WorkflowEditTriggerWebhookForm = ({ onRightIconClick={copyToClipboardDebounced} readOnly /> + { + if (triggerOptions.readonly === true) { + return; + } + + triggerOptions.onTriggerUpdate({ + ...trigger, + settings: { + ...trigger.settings, + authentication: newAuthenticationType, + }, + }); + }} + /> ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions.ts new file mode 100644 index 000000000..64a1c47c5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions.ts @@ -0,0 +1,19 @@ +import { IconComponent, IconLockOpen, IconFlag } from 'twenty-ui/display'; +export type AuthenticationMethods = 'API_KEY' | null; + +export const WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS: Array<{ + label: string; + value: AuthenticationMethods; + Icon: IconComponent; +}> = [ + { + label: 'None', + value: null, + Icon: IconLockOpen, + }, + { + label: 'API key', + value: 'API_KEY', + Icon: IconFlag, + }, +]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions.ts new file mode 100644 index 000000000..d7683a5f1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions.ts @@ -0,0 +1,19 @@ +import { IconComponent, IconHttpPost, IconHttpGet } from 'twenty-ui/display'; +export type WebhookHttpMethods = 'GET' | 'POST'; + +export const WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS: Array<{ + label: string; + value: WebhookHttpMethods; + Icon: IconComponent; +}> = [ + { + label: 'Get', + value: 'GET', + Icon: IconHttpGet, + }, + { + label: 'Post', + value: 'POST', + Icon: IconHttpPost, + }, +]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts index fea9943fa..8032e762b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts @@ -23,23 +23,28 @@ export const useUpdateWorkflowVersionTrigger = ({ const { computeStepOutputSchema } = useComputeStepOutputSchema(); - const updateTrigger = async (updatedTrigger: WorkflowTrigger) => { + const updateTrigger = async ( + updatedTrigger: WorkflowTrigger, + options: { computeOutputSchema: boolean } = { computeOutputSchema: true }, + ) => { if (!isDefined(workflow.currentVersion)) { throw new Error('Can not update an undefined workflow version.'); } const workflowVersionId = await getUpdatableWorkflowVersion(workflow); - const outputSchema = ( - await computeStepOutputSchema({ - step: updatedTrigger, - }) - )?.data?.computeStepOutputSchema; + if (options.computeOutputSchema) { + const outputSchema = ( + await computeStepOutputSchema({ + step: updatedTrigger, + }) + )?.data?.computeStepOutputSchema; - updatedTrigger.settings = { - ...updatedTrigger.settings, - outputSchema: outputSchema || {}, - }; + updatedTrigger.settings = { + ...updatedTrigger.settings, + outputSchema: outputSchema || {}, + }; + } await updateOneWorkflowVersion({ idToUpdate: workflowVersionId, diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts new file mode 100644 index 000000000..a19c942ef --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts @@ -0,0 +1,39 @@ +import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings'; + +describe('getWebhookTriggerDefaultSettings', () => { + it('returns correct settings for GET http method', () => { + const result = getWebhookTriggerDefaultSettings('GET'); + expect(result).toEqual({ + authentication: null, + httpMethod: 'GET', + outputSchema: {}, + }); + }); + + it('returns correct settings for POST http method', () => { + const result = getWebhookTriggerDefaultSettings('POST'); + expect(result).toEqual({ + authentication: null, + httpMethod: 'POST', + expectedBody: { + message: 'Workflow was started', + }, + outputSchema: { + message: { + icon: 'IconVariable', + isLeaf: true, + label: 'message', + type: 'string', + value: 'Workflow was started', + }, + }, + }); + }); + + it('throws an error for an invalid http method', () => { + // @ts-expect-error Testing invalid input + expect(() => getWebhookTriggerDefaultSettings('INVALID')).toThrowError( + 'Invalid webhook http method', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts index 128281612..62503fdbf 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts @@ -64,6 +64,8 @@ export const getTriggerDefaultDefinition = ({ name: defaultLabel, settings: { outputSchema: {}, + httpMethod: 'GET', + authentication: null, }, }; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts new file mode 100644 index 000000000..26b48ce2b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts @@ -0,0 +1,34 @@ +import { WorkflowWebhookTrigger } from '@/workflow/types/Workflow'; +import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; +import { WebhookHttpMethods } from '@/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions'; + +export const getWebhookTriggerDefaultSettings = ( + webhookHttpMethods: WebhookHttpMethods, +): WorkflowWebhookTrigger['settings'] => { + switch (webhookHttpMethods) { + case 'GET': + return { + outputSchema: {}, + httpMethod: webhookHttpMethods, + authentication: null, + }; + case 'POST': + return { + outputSchema: { + message: { + icon: 'IconVariable', + type: 'string', + label: 'message', + value: 'Workflow was started', + isLeaf: true, + }, + }, + httpMethod: webhookHttpMethods, + expectedBody: { + message: 'Workflow was started', + }, + authentication: null, + }; + } + return assertUnreachable(webhookHttpMethods, 'Invalid webhook http method'); +}; diff --git a/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts b/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts index 24a6b0ed7..349a1da93 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Param, UseFilters } from '@nestjs/common'; +import { Controller, Get, Param, Post, Req, UseFilters } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; +import { Request } from 'express'; import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; @@ -22,11 +23,26 @@ export class WorkflowTriggerController { private readonly workflowTriggerWorkspaceService: WorkflowTriggerWorkspaceService, ) {} - @Get('workflows/:workspaceId/:workflowId') - async runWorkflow( - @Param('workspaceId') workspaceId: string, + @Post('workflows/:workspaceId/:workflowId') + async runWorkflowByPostRequest( @Param('workflowId') workflowId: string, + @Req() request: Request, ) { + return await this.runWorkflow({ workflowId, payload: request.body || {} }); + } + + @Get('workflows/:workspaceId/:workflowId') + async runWorkflowByGetRequest(@Param('workflowId') workflowId: string) { + return await this.runWorkflow({ workflowId }); + } + + private async runWorkflow({ + workflowId, + payload, + }: { + workflowId: string; + payload?: object; + }) { const workflowRepository = await this.twentyORMManager.getRepository( 'workflow', @@ -78,7 +94,7 @@ export class WorkflowTriggerController { const { workflowRunId } = await this.workflowTriggerWorkspaceService.runWorkflowVersion({ workflowVersionId: workflow.lastPublishedVersionId, - payload: {}, + payload: payload || {}, createdBy: { source: FieldActorSource.WEBHOOK, workspaceMemberId: null, diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts index 3a7a2dd9d..4fd723a91 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts @@ -61,6 +61,16 @@ export type WorkflowCronTrigger = BaseTrigger & { export type WorkflowWebhookTrigger = BaseTrigger & { type: WorkflowTriggerType.WEBHOOK; + settings: + | { + httpMethod: 'GET'; + authentication: 'API_KEY' | null; + } + | ({ + httpMethod: 'POST'; + authentication: 'API_KEY' | null; + expectedBody: object; + } & { outputSchema: object }); }; export type WorkflowManualTriggerSettings = WorkflowManualTrigger['settings']; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index f438b8ec6..1bc8d8031 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -169,6 +169,8 @@ export { IconHistoryToggle, IconHome, IconHours24, + IconHttpGet, + IconHttpPost, IconId, IconInbox, IconInfoCircle, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index f1054e1f5..49d5dcd1e 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -230,6 +230,8 @@ export { IconHistoryToggle, IconHome, IconHours24, + IconHttpGet, + IconHttpPost, IconId, IconInbox, IconInfoCircle,