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:
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -64,6 +64,8 @@ export const getTriggerDefaultDefinition = ({
|
||||
name: defaultLabel,
|
||||
settings: {
|
||||
outputSchema: {},
|
||||
httpMethod: 'GET',
|
||||
authentication: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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');
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -169,6 +169,8 @@ export {
|
||||
IconHistoryToggle,
|
||||
IconHome,
|
||||
IconHours24,
|
||||
IconHttpGet,
|
||||
IconHttpPost,
|
||||
IconId,
|
||||
IconInbox,
|
||||
IconInfoCircle,
|
||||
|
||||
@ -230,6 +230,8 @@ export {
|
||||
IconHistoryToggle,
|
||||
IconHome,
|
||||
IconHours24,
|
||||
IconHttpGet,
|
||||
IconHttpPost,
|
||||
IconId,
|
||||
IconInbox,
|
||||
IconInfoCircle,
|
||||
|
||||
Reference in New Issue
Block a user