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
This commit is contained in:
martmull
2025-04-08 21:01:22 +02:00
committed by GitHub
parent 2f7f28a574
commit f121c94d4a
14 changed files with 297 additions and 20 deletions

View File

@ -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 = ({
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker) && !readonly}
multiline
onBlur={onBlur}
>
<TextVariableEditor editor={editor} multiline readonly={readonly} />
</FormFieldInputInputContainer>
@ -80,6 +86,7 @@ export const FormRawJsonFieldInput = ({
/>
)}
</FormFieldInputRowContainer>
<InputErrorHelper>{error}</InputErrorHelper>
</FormFieldInputContainer>
);
};

View File

@ -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 = ({

View File

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

View File

@ -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<FormErrorMessages>({});
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
/>
<Select
dropdownId="workflow-edit-webhook-trigger-http-method"
label="HTTP method"
fullWidth
disabled={triggerOptions.readonly}
value={trigger.settings.httpMethod}
options={WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS}
onChange={(newTriggerType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
{
...trigger,
settings: getWebhookTriggerDefaultSettings(newTriggerType),
},
{ computeOutputSchema: false },
);
}}
/>
{trigger.settings.httpMethod === 'POST' && (
<FormRawJsonFieldInput
label="Expected Body"
placeholder="Enter a JSON object"
error={
errorMessagesVisible ? errorMessages.expectedBody : undefined
}
onBlur={onBlur}
readonly={triggerOptions.readonly}
defaultValue={JSON.stringify(trigger.settings.expectedBody)}
onChange={(newExpectedBody) => {
if (triggerOptions.readonly === true) {
return;
}
let formattedExpectedBody = {};
try {
formattedExpectedBody = JSON.parse(newExpectedBody || '{}');
} catch (e) {
setErrorMessages((prev) => ({
...prev,
expectedBody: String(e),
}));
return;
}
setErrorMessages((prev) => ({
...prev,
expectedBody: undefined,
}));
const outputSchema = getFunctionOutputSchema(
formattedExpectedBody,
);
triggerOptions.onTriggerUpdate(
{
...trigger,
settings: {
...trigger.settings,
expectedBody: formattedExpectedBody,
outputSchema,
} as WorkflowWebhookTrigger['settings'],
},
{ computeOutputSchema: false },
);
}}
/>
)}
<Select
dropdownId="workflow-edit-webhook-trigger-auth"
label="Auth"
fullWidth
disabled
value={trigger.settings.authentication}
options={WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS}
onChange={(newAuthenticationType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
authentication: newAuthenticationType,
},
});
}}
/>
</WorkflowStepBody>
</>
);

View File

@ -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,
},
];

View File

@ -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,
},
];

View File

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

View File

@ -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',
);
});
});

View File

@ -64,6 +64,8 @@ export const getTriggerDefaultDefinition = ({
name: defaultLabel,
settings: {
outputSchema: {},
httpMethod: 'GET',
authentication: null,
},
};
}

View File

@ -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');
};

View File

@ -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<WorkflowWorkspaceEntity>(
'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,

View File

@ -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'];

View File

@ -169,6 +169,8 @@ export {
IconHistoryToggle,
IconHome,
IconHours24,
IconHttpGet,
IconHttpPost,
IconId,
IconInbox,
IconInfoCircle,

View File

@ -230,6 +230,8 @@ export {
IconHistoryToggle,
IconHome,
IconHours24,
IconHttpGet,
IconHttpPost,
IconId,
IconInbox,
IconInfoCircle,