Feature - HTTP request node (#12509)

Closes [#1072](https://github.com/twentyhq/core-team-issues/issues/1072)



https://github.com/user-attachments/assets/adff3474-6ec3-4369-a0c8-fb4be7defe85

---------

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: guillim <guigloo@msn.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Jordan Chalupka <9794216+jordan-chalupka@users.noreply.github.com>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: jaspass04 <147055860+jaspass04@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: Matt Dvertola <64113801+mdvertola@users.noreply.github.com>
Co-authored-by: Zeroday BYTE <github@zerodaysec.org>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: Karuna Tata <karuna.tata@devrev.ai>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Ajay A Adsule <103304466+AjayAdsule@users.noreply.github.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: oliver <8559757+oliverqx@users.noreply.github.com>
Co-authored-by: Ahmad Zaheer <55204917+ahmadzaheer-dev@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-13 17:11:22 +05:30
committed by GitHub
parent e9733ea33a
commit 19b7ab57b9
46 changed files with 2373 additions and 55 deletions

View File

@ -13,6 +13,7 @@ import {
workflowFindRecordsActionSettingsSchema,
workflowFormActionSchema,
workflowFormActionSettingsSchema,
workflowHttpRequestActionSchema,
workflowManualTriggerSchema,
workflowRunContextSchema,
workflowRunOutputSchema,
@ -67,6 +68,9 @@ export type WorkflowFindRecordsAction = z.infer<
typeof workflowFindRecordsActionSchema
>;
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
export type WorkflowHttpRequestAction = z.infer<
typeof workflowHttpRequestActionSchema
>;
export type WorkflowAction = z.infer<typeof workflowActionSchema>;
export type WorkflowActionType = WorkflowAction['type'];

View File

@ -110,6 +110,26 @@ export const workflowFormActionSettingsSchema =
),
});
export const workflowHttpRequestActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
url: z.string().url(),
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
headers: z.record(z.string()).optional(),
body: z
.record(
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])),
]),
)
.optional(),
}),
});
// Action schemas
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('CODE'),
@ -152,6 +172,11 @@ export const workflowFormActionSchema = baseWorkflowActionSchema.extend({
settings: workflowFormActionSettingsSchema,
});
export const workflowHttpRequestActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('HTTP_REQUEST'),
settings: workflowHttpRequestActionSettingsSchema,
});
// Combined action schema
export const workflowActionSchema = z.discriminatedUnion('type', [
workflowCodeActionSchema,
@ -161,6 +186,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
workflowDeleteRecordActionSchema,
workflowFindRecordsActionSchema,
workflowFormActionSchema,
workflowHttpRequestActionSchema,
]);
// Trigger schemas

View File

@ -1,4 +1,3 @@
import React from 'react';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
@ -46,7 +45,8 @@ export const WorkflowDiagramStepNodeIcon = ({
}
case 'action': {
switch (data.actionType) {
case 'CODE': {
case 'CODE':
case 'HTTP_REQUEST': {
return (
<StyledStepNodeLabelIconContainer>
<Icon

View File

@ -68,6 +68,11 @@ const ALL_STEPS = [
actionType: 'CODE',
name: 'Code',
},
{
nodeType: 'action',
actionType: 'HTTP_REQUEST',
name: 'HTTP Request',
},
] satisfies WorkflowDiagramStepNodeData[];
export const Catalog: CatalogStory<Story, typeof Wrapper> = {

View File

@ -9,6 +9,7 @@ import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller';
import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
@ -175,6 +176,18 @@ export const WorkflowRunStepNodeDetail = ({
/>
);
}
case 'HTTP_REQUEST': {
return (
<WorkflowEditActionHttpRequest
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: true,
}}
/>
);
}
}
}
}

View File

@ -8,6 +8,7 @@ import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
@ -163,6 +164,16 @@ export const WorkflowStepDetail = ({
/>
);
}
case 'HTTP_REQUEST': {
return (
<WorkflowEditActionHttpRequest
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
}
}
}

View File

@ -7,9 +7,8 @@ import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOr
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@ -17,7 +16,6 @@ import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
type WorkflowEditActionCreateRecordProps = {
action: WorkflowCreateRecordAction;
@ -159,10 +157,11 @@ export const WorkflowEditActionCreateRecord = ({
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Create Record',
});
return (
<>

View File

@ -6,9 +6,7 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo
import { useEffect, useState } from 'react';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
@ -104,10 +102,11 @@ export const WorkflowEditActionDeleteRecord = ({
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Delete Record',
});
return (
<>

View File

@ -13,9 +13,7 @@ import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/wo
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
@ -197,10 +195,11 @@ export const WorkflowEditActionSendEmail = ({
}
});
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Send Email',
});
const navigate = useNavigateSettings();

View File

@ -10,9 +10,7 @@ import { isFieldRelation } from '@/object-record/record-field/types/guards/isFie
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { isDefined } from 'twenty-shared/utils';
@ -139,10 +137,11 @@ export const WorkflowEditActionUpdateRecord = ({
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Update Record`;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Update Record',
});
return (
<>

View File

@ -23,4 +23,9 @@ export const OTHER_ACTIONS: Array<{
type: 'FORM',
icon: 'IconForms',
},
{
label: 'HTTP Request',
type: 'HTTP_REQUEST',
icon: 'IconWorld',
},
];

View File

@ -15,9 +15,7 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters';
import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
@ -115,10 +113,11 @@ export const WorkflowEditActionFindRecords = ({
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Search Records`;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Search Records',
});
const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectName}`;
return (

View File

@ -0,0 +1,219 @@
import {
workflowActionSchema,
workflowFormActionSettingsSchema,
workflowHttpRequestActionSettingsSchema,
workflowSendEmailActionSettingsSchema,
} from '@/workflow/validation-schemas/workflowSchema';
import { renderHook } from '@testing-library/react';
import { FieldMetadataType } from 'twenty-shared/types';
import { useWorkflowActionHeader } from '../useWorkflowActionHeader';
jest.mock('../useActionIconColorOrThrow', () => ({
useActionIconColorOrThrow: jest.fn().mockReturnValue('blue'),
}));
jest.mock('../useActionHeaderTypeOrThrow', () => ({
useActionHeaderTypeOrThrow: jest.fn().mockReturnValue('Action'),
}));
jest.mock('../../utils/getActionIcon', () => ({
getActionIcon: jest.fn().mockReturnValue('IconHttp'),
}));
const mockGetIcon = jest.fn().mockReturnValue('IconComponent');
jest.mock('twenty-ui/display', () => ({
useIcons: () => ({
getIcon: mockGetIcon,
}),
}));
describe('useWorkflowActionHeader', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when action name is not defined', () => {
it('should return default title', () => {
const action = workflowActionSchema.parse({
id: '1',
name: '',
type: 'HTTP_REQUEST',
settings: workflowHttpRequestActionSettingsSchema.parse({
input: {
url: 'https://example.com',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
});
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
}),
);
expect(result.current.headerTitle).toBe('HTTP Request');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
});
describe('when action name is defined', () => {
it('should return the action name', () => {
const action = workflowActionSchema.parse({
id: '1',
name: 'Test Action',
type: 'HTTP_REQUEST',
settings: workflowHttpRequestActionSettingsSchema.parse({
input: {
url: 'https://example.com',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
});
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
}),
);
expect(result.current.headerTitle).toBe('Test Action');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
});
describe('when action type is defined', () => {
it('should return default title for HTTP request action', () => {
const action = workflowActionSchema.parse({
id: '1',
name: '',
type: 'HTTP_REQUEST',
settings: workflowHttpRequestActionSettingsSchema.parse({
input: {
url: 'https://example.com',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
});
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
}),
);
expect(result.current.headerTitle).toBe('HTTP Request');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
it('should return default title for form action', () => {
const action = workflowActionSchema.parse({
id: '1',
name: '',
type: 'FORM',
settings: workflowFormActionSettingsSchema.parse({
input: [
{
id: '1',
name: 'field1',
label: 'Field 1',
type: FieldMetadataType.TEXT,
required: false,
settings: {},
},
],
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
});
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'Form',
}),
);
expect(result.current.headerTitle).toBe('Form');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
it('should return default title for email action', () => {
const action = workflowActionSchema.parse({
id: '1',
name: '',
type: 'SEND_EMAIL',
settings: workflowSendEmailActionSettingsSchema.parse({
input: {
connectedAccountId: '1',
email: 'test@example.com',
subject: 'Test Subject',
body: 'Test Body',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
});
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'Email',
}),
);
expect(result.current.headerTitle).toBe('Email');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
});
});

View File

@ -0,0 +1,38 @@
import { WorkflowAction } from '@/workflow/types/Workflow';
import { IconComponent, useIcons } from 'twenty-ui/display';
import { getActionIcon } from '../utils/getActionIcon';
import { useActionHeaderTypeOrThrow } from './useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from './useActionIconColorOrThrow';
type UseWorkflowActionHeaderProps = {
action: WorkflowAction;
defaultTitle: string;
};
type UseWorkflowActionHeaderReturn = {
headerTitle: string;
headerIcon: string | undefined;
headerIconColor: string;
headerType: string;
getIcon: (iconName: string) => IconComponent;
};
export const useWorkflowActionHeader = ({
action,
defaultTitle,
}: UseWorkflowActionHeaderProps): UseWorkflowActionHeaderReturn => {
const { getIcon } = useIcons();
const headerTitle = action.name ? action.name : defaultTitle;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
return {
headerTitle,
headerIcon,
headerIconColor,
headerType,
getIcon,
};
};

View File

@ -0,0 +1,134 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import {
DEFAULT_JSON_BODY_PLACEHOLDER,
HttpRequestBody,
} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { hasNonStringValues } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconFileText, IconKey } from 'twenty-ui/display';
import { KeyValuePairInput } from './KeyValuePairInput';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledSelectDropdown = styled(Select)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
type BodyInputProps = {
label?: string;
defaultValue?: HttpRequestBody;
onChange: (value?: HttpRequestBody) => void;
readonly?: boolean;
};
export const BodyInput = ({
defaultValue,
onChange,
readonly,
}: BodyInputProps) => {
const [isRawJson, setIsRawJson] = useState<boolean>(() =>
hasNonStringValues(defaultValue),
);
const [jsonString, setJsonString] = useState<string | null>(
JSON.stringify(defaultValue, null, 2),
);
const [errorMessage, setErrorMessage] = useState<string | undefined>();
const validateJson = (value: string | null): boolean => {
if (!value?.trim()) {
setErrorMessage(undefined);
return true;
}
try {
JSON.parse(value);
setErrorMessage(undefined);
return true;
} catch (e) {
setErrorMessage(String(e));
return false;
}
};
const handleKeyValueChange = (value: Record<string, string>) => {
onChange(value);
setErrorMessage(undefined);
};
const handleJsonChange = (value: string | null) => {
setJsonString(value);
if (!value?.trim()) {
onChange();
setErrorMessage(undefined);
return;
}
try {
const parsed = JSON.parse(value);
onChange(parsed);
} catch {
// Do nothing, validation will happen on blur
}
};
const handleModeChange = (isRawJson: boolean) => {
setIsRawJson(isRawJson);
onChange();
setJsonString(null);
};
const handleBlur = () => {
if (isRawJson && Boolean(jsonString)) {
validateJson(jsonString);
}
};
return (
<FormFieldInputContainer>
<InputLabel>Body Input</InputLabel>
<StyledSelectDropdown
options={[
{ label: 'Key/Value', value: 'keyValue', Icon: IconKey },
{ label: 'Raw JSON', value: 'rawJson', Icon: IconFileText },
]}
dropdownId="body-input-mode"
value={isRawJson ? 'rawJson' : 'keyValue'}
onChange={(value) => handleModeChange(value === 'rawJson')}
disabled={readonly}
/>
<StyledContainer>
{isRawJson ? (
<FormRawJsonFieldInput
placeholder={DEFAULT_JSON_BODY_PLACEHOLDER}
readonly={readonly}
defaultValue={jsonString}
error={errorMessage}
onBlur={handleBlur}
onChange={handleJsonChange}
VariablePicker={WorkflowVariablePicker}
/>
) : (
<KeyValuePairInput
defaultValue={defaultValue as Record<string, string>}
onChange={handleKeyValueChange}
readonly={readonly}
keyPlaceholder="Property name"
valuePlaceholder="Property value"
/>
)}
</StyledContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,159 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { v4 } from 'uuid';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledRow = styled.div`
align-items: flex-start;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledKeyValueContainer = styled.div`
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledPlaceholder = styled.div`
aspect-ratio: 1;
height: ${({ theme }) => theme.spacing(8)};
`;
export type KeyValuePair = {
id: string;
key: string;
value: string;
};
export type KeyValuePairInputProps = {
label?: string;
defaultValue?: Record<string, string>;
onChange: (value: Record<string, string>) => void;
readonly?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
};
export const KeyValuePairInput = ({
label,
defaultValue = {},
onChange,
readonly,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
}: KeyValuePairInputProps) => {
const [pairs, setPairs] = useState<KeyValuePair[]>(() => {
const initialPairs = Object.entries(defaultValue).map(([key, value]) => ({
id: v4(),
key,
value,
}));
return initialPairs.length > 0
? [...initialPairs, { id: v4(), key: '', value: '' }]
: [{ id: v4(), key: '', value: '' }];
});
const handlePairChange = (
pairId: string,
field: 'key' | 'value',
newValue: string,
) => {
const index = pairs.findIndex((p) => p.id === pairId);
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], [field]: newValue };
if (
index === pairs.length - 1 &&
(field === 'key' || field === 'value') &&
Boolean(newValue.trim())
) {
newPairs.push({ id: v4(), key: '', value: '' });
}
setPairs(newPairs);
const record = newPairs.reduce(
(acc, { key, value }) => {
if (key.trim().length > 0) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
);
onChange(record);
};
const handleRemovePair = (pairId: string) => {
const newPairs = pairs.filter((pair) => pair.id !== pairId);
if (newPairs.length === 0) {
newPairs.push({ id: v4(), key: '', value: '' });
}
setPairs(newPairs);
const record = newPairs.reduce(
(acc, { key, value }) => {
if (key.trim().length > 0) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
);
onChange(record);
};
return (
<FormFieldInputContainer>
{label && <InputLabel>{label}</InputLabel>}
<StyledContainer>
{pairs.map((pair) => (
<StyledRow key={pair.id}>
<StyledKeyValueContainer>
<FormTextFieldInput
placeholder={keyPlaceholder}
readonly={readonly}
defaultValue={pair.key}
onChange={(value) =>
handlePairChange(pair.id, 'key', value ?? '')
}
VariablePicker={WorkflowVariablePicker}
/>
<FormTextFieldInput
placeholder={valuePlaceholder}
readonly={readonly}
defaultValue={pair.value}
onChange={(value) =>
handlePairChange(pair.id, 'value', value ?? '')
}
VariablePicker={WorkflowVariablePicker}
/>
{!readonly && pair.id !== pairs[pairs.length - 1].id ? (
<Button
onClick={() => handleRemovePair(pair.id)}
Icon={IconTrash}
/>
) : pairs.length > 1 ? (
<StyledPlaceholder />
) : null}
</StyledKeyValueContainer>
</StyledRow>
))}
</StyledContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,117 @@
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useEffect } from 'react';
import { useIcons } from 'twenty-ui/display';
import {
HTTP_METHODS,
JSON_RESPONSE_PLACEHOLDER,
} from '../constants/HttpRequest';
import { useHttpRequestForm } from '../hooks/useHttpRequestForm';
import { useHttpRequestOutputSchema } from '../hooks/useHttpRequestOutputSchema';
import { BodyInput } from './BodyInput';
import { KeyValuePairInput } from './KeyValuePairInput';
type WorkflowEditActionHttpRequestProps = {
action: WorkflowHttpRequestAction;
actionOptions: {
readonly?: boolean;
onActionUpdate?: (action: WorkflowHttpRequestAction) => void;
};
};
export const WorkflowEditActionHttpRequest = ({
action,
actionOptions,
}: WorkflowEditActionHttpRequestProps) => {
const { getIcon } = useIcons();
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
});
const { formData, handleFieldChange, saveAction } = useHttpRequestForm({
action,
onActionUpdate: actionOptions.onActionUpdate,
readonly: actionOptions.readonly === true,
});
const { outputSchema, handleOutputSchemaChange, error } =
useHttpRequestOutputSchema({
action,
onActionUpdate: actionOptions.onActionUpdate,
readonly: actionOptions.readonly === true,
});
useEffect(() => () => saveAction.flush(), [saveAction]);
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
<FormTextFieldInput
label="URL"
placeholder="https://api.example.com/endpoint"
readonly={actionOptions.readonly}
defaultValue={formData.url}
onChange={(value) => handleFieldChange('url', value)}
VariablePicker={WorkflowVariablePicker}
/>
<Select
label="HTTP Method"
dropdownId="http-method"
options={[...HTTP_METHODS]}
value={formData.method}
onChange={(value) => handleFieldChange('method', value)}
disabled={actionOptions.readonly}
/>
<KeyValuePairInput
label="Headers Input"
defaultValue={formData.headers}
onChange={(value) => handleFieldChange('headers', value)}
readonly={actionOptions.readonly}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
/>
{isMethodWithBody(formData.method) && (
<BodyInput
defaultValue={formData.body}
onChange={(value) => handleFieldChange('body', value)}
readonly={actionOptions.readonly}
/>
)}
<FormRawJsonFieldInput
label="Expected Body Output"
placeholder={JSON_RESPONSE_PLACEHOLDER}
defaultValue={outputSchema}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
error={error}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,151 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionHttpRequest } from '../WorkflowEditActionHttpRequest';
const DEFAULT_ACTION: WorkflowHttpRequestAction = {
id: getWorkflowNodeIdMock(),
name: 'HTTP Request',
type: 'HTTP_REQUEST',
valid: false,
settings: {
input: {
url: '',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const CONFIGURED_ACTION: WorkflowHttpRequestAction = {
id: getWorkflowNodeIdMock(),
name: 'API Call',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: {
url: 'https://api.example.com/data',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token123',
},
body: {
name: 'Test',
value: 123,
},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: true,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionHttpRequest> = {
title: 'Modules/Workflow/WorkflowEditActionHttpRequest',
component: WorkflowEditActionHttpRequest,
parameters: {
msw: graphqlMocks,
},
args: {
action: DEFAULT_ACTION,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
SnackBarDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionHttpRequest>;
export const Default: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('URL')).toBeVisible();
expect(await canvas.findByText('HTTP Method')).toBeVisible();
expect(await canvas.findByText('Headers Input')).toBeVisible();
},
};
export const Configured: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const header = await canvas.findByTestId('workflow-step-header');
const headerCanvas = within(header);
expect(await headerCanvas.findByText('API Call')).toBeVisible();
const urlLabel = await canvas.findByText('URL');
const urlInputContainer = urlLabel.closest('div')?.nextElementSibling;
const urlEditor = urlInputContainer?.querySelector('.ProseMirror');
expect(urlEditor).toBeVisible();
expect(urlEditor).toHaveTextContent('https://api.example.com/data');
expect(await canvas.findByText('POST')).toBeVisible();
expect(await canvas.findByText('Body Input')).toBeVisible();
},
};
export const ReadOnly: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
readonly: true,
} as const,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const urlLabel = await canvas.findByText('URL');
const urlInputContainer = urlLabel.closest('div')?.nextElementSibling;
const urlEditor = urlInputContainer?.querySelector('.ProseMirror');
expect(urlEditor).toBeVisible();
expect(urlEditor).toHaveTextContent('https://api.example.com/data');
expect(urlEditor).toHaveAttribute('contenteditable', 'false');
const methodSelect = await canvas.findByText('POST');
expect(methodSelect).toBeVisible();
},
};

View File

@ -0,0 +1,30 @@
export const HTTP_METHODS = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
] as const;
export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH'] as const;
export type HttpMethodWithBody = (typeof METHODS_WITH_BODY)[number];
export type HttpMethod = (typeof HTTP_METHODS)[number]['value'];
export type HttpRequestBody = Record<
string,
string | number | boolean | null | Array<string | number | boolean | null>
>;
export type HttpRequestFormData = {
url: string;
method: HttpMethod;
headers: Record<string, string>;
body?: HttpRequestBody;
};
export const DEFAULT_JSON_BODY_PLACEHOLDER =
'{\n "key": "value"\n "another_key": "{{workflow.variable}}" \n}';
export const JSON_RESPONSE_PLACEHOLDER =
'{\n Paste expected call response here to use its keys later in the workflow \n}';

View File

@ -0,0 +1,112 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { act, renderHook } from '@testing-library/react';
import { useHttpRequestForm } from '../useHttpRequestForm';
describe('useHttpRequestForm', () => {
const mockAction: WorkflowHttpRequestAction = {
id: 'test-id',
name: 'Test HTTP Request',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: {
url: 'https://api.example.com',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: undefined,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
const mockOnActionUpdate = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should initialize with correct form data', () => {
const { result } = renderHook(() =>
useHttpRequestForm({
action: mockAction,
onActionUpdate: mockOnActionUpdate,
readonly: false,
}),
);
expect(result.current.formData).toEqual({
url: 'https://api.example.com',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: undefined,
});
});
it('should handle body changes for POST method', () => {
const postAction: WorkflowHttpRequestAction = {
...mockAction,
settings: {
...mockAction.settings,
input: {
...mockAction.settings.input,
method: 'POST',
},
},
};
const { result } = renderHook(() =>
useHttpRequestForm({
action: postAction,
onActionUpdate: mockOnActionUpdate,
readonly: false,
}),
);
act(() => {
result.current.handleFieldChange('body', { data: 'test' });
});
act(() => {
jest.advanceTimersByTime(500);
});
expect(mockOnActionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
input: expect.objectContaining({
body: { data: 'test' },
}),
}),
}),
);
});
it('should handle readonly mode', () => {
const { result } = renderHook(() =>
useHttpRequestForm({
action: mockAction,
onActionUpdate: mockOnActionUpdate,
readonly: true,
}),
);
act(() => {
result.current.handleFieldChange('url', 'https://new-url.com');
});
act(() => {
jest.advanceTimersByTime(500);
});
expect(mockOnActionUpdate).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,63 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { HttpRequestBody, HttpRequestFormData } from '../constants/HttpRequest';
export type UseHttpRequestFormParams = {
action: WorkflowHttpRequestAction;
onActionUpdate?: (action: WorkflowHttpRequestAction) => void;
readonly: boolean;
};
export const useHttpRequestForm = ({
action,
onActionUpdate,
readonly,
}: UseHttpRequestFormParams) => {
const [formData, setFormData] = useState<HttpRequestFormData>({
url: action.settings.input.url,
method: action.settings.input.method,
headers: action.settings.input.headers || {},
body: action.settings.input.body,
});
const saveAction = useDebouncedCallback((formData: HttpRequestFormData) => {
if (readonly) {
return;
}
onActionUpdate?.({
...action,
settings: {
...action.settings,
input: {
url: formData.url,
method: formData.method,
headers: formData.headers,
body: formData.body,
},
},
});
}, 500);
const handleFieldChange = (
field: keyof HttpRequestFormData,
value: string | HttpRequestBody | undefined,
) => {
let newFormData = { ...formData, [field]: value };
if (field === 'method' && !isMethodWithBody(value as string)) {
newFormData = { ...newFormData, body: undefined };
}
setFormData(newFormData);
saveAction(newFormData);
};
return {
formData,
handleFieldChange,
saveAction,
};
};

View File

@ -0,0 +1,61 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { useState } from 'react';
import { convertOutputSchemaToJson } from '../utils/convertOutputSchemaToJson';
import { getHttpRequestOutputSchema } from '../utils/getHttpRequestOutputSchema';
type UseHttpRequestOutputSchemaProps = {
action: WorkflowHttpRequestAction;
onActionUpdate?: (action: WorkflowHttpRequestAction) => void;
readonly?: boolean;
};
export const useHttpRequestOutputSchema = ({
action,
onActionUpdate,
readonly,
}: UseHttpRequestOutputSchemaProps) => {
const [outputSchema, setOutputSchema] = useState<string | null>(
Object.keys(action.settings.outputSchema).length
? JSON.stringify(
convertOutputSchemaToJson(
action.settings.outputSchema as BaseOutputSchema,
),
null,
2,
)
: null,
);
const [error, setError] = useState<string | undefined>();
const handleOutputSchemaChange = (value: string | null) => {
if (value === null || value === '' || readonly === true) {
setError(undefined);
return;
}
setOutputSchema(value);
try {
const parsedJson = JSON.parse(value);
const outputSchema = getHttpRequestOutputSchema(parsedJson);
onActionUpdate?.({
...action,
settings: {
...action.settings,
outputSchema,
},
});
setError(undefined);
} catch (error) {
setError(String(error));
}
};
return {
outputSchema,
handleOutputSchemaChange,
error,
};
};

View File

@ -0,0 +1,419 @@
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { convertOutputSchemaToJson } from '../convertOutputSchemaToJson';
describe('convertOutputSchemaToJson', () => {
it('should convert simple object schema to JSON', () => {
const schema: BaseOutputSchema = {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconText',
},
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 25,
icon: 'IconNumber',
},
isActive: {
isLeaf: true,
type: 'boolean',
label: 'isActive',
value: true,
icon: 'IconCheckbox',
},
};
const expected = {
name: 'John',
age: 25,
isActive: true,
};
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should convert array schema to JSON array', () => {
const schema: BaseOutputSchema = {
'0': {
isLeaf: true,
type: 'string',
label: '0',
value: 'first',
icon: 'IconText',
},
'1': {
isLeaf: true,
type: 'string',
label: '1',
value: 'second',
icon: 'IconText',
},
'2': {
isLeaf: true,
type: 'string',
label: '2',
value: 'third',
icon: 'IconText',
},
};
const expected = ['first', 'second', 'third'];
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should convert nested object schema to JSON', () => {
const schema: BaseOutputSchema = {
user: {
isLeaf: false,
label: 'user',
value: {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconText',
},
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 25,
icon: 'IconNumber',
},
address: {
isLeaf: false,
label: 'address',
value: {
city: {
isLeaf: true,
type: 'string',
label: 'city',
value: 'New York',
icon: 'IconText',
},
country: {
isLeaf: true,
type: 'string',
label: 'country',
value: 'USA',
icon: 'IconText',
},
},
icon: 'IconBox',
},
},
icon: 'IconBox',
},
};
const expected = {
user: {
name: 'John',
age: 25,
address: {
city: 'New York',
country: 'USA',
},
},
};
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should convert nested array schema to JSON', () => {
const schema: BaseOutputSchema = {
'0': {
isLeaf: false,
label: '0',
value: {
'0': {
isLeaf: true,
type: 'number',
label: '0',
value: 1,
icon: 'IconNumber',
},
'1': {
isLeaf: true,
type: 'number',
label: '1',
value: 2,
icon: 'IconNumber',
},
},
icon: 'IconBox',
},
'1': {
isLeaf: false,
label: '1',
value: {
'0': {
isLeaf: true,
type: 'number',
label: '0',
value: 3,
icon: 'IconNumber',
},
'1': {
isLeaf: true,
type: 'number',
label: '1',
value: 4,
icon: 'IconNumber',
},
},
icon: 'IconBox',
},
};
const expected = [
[1, 2],
[3, 4],
];
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should handle mixed array and object schema', () => {
const schema: BaseOutputSchema = {
users: {
isLeaf: false,
label: 'users',
value: {
'0': {
isLeaf: false,
label: '0',
value: {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconText',
},
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 25,
icon: 'IconNumber',
},
},
icon: 'IconBox',
},
'1': {
isLeaf: false,
label: '1',
value: {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'Jane',
icon: 'IconText',
},
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 30,
icon: 'IconNumber',
},
},
icon: 'IconBox',
},
},
icon: 'IconBox',
},
metadata: {
isLeaf: false,
label: 'metadata',
value: {
count: {
isLeaf: true,
type: 'number',
label: 'count',
value: 2,
icon: 'IconNumber',
},
active: {
isLeaf: true,
type: 'boolean',
label: 'active',
value: true,
icon: 'IconCheckbox',
},
},
icon: 'IconBox',
},
};
const expected = {
users: [
{
name: 'John',
age: 25,
},
{
name: 'Jane',
age: 30,
},
],
metadata: {
count: 2,
active: true,
},
};
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should handle null values', () => {
const schema: BaseOutputSchema = {
name: {
isLeaf: true,
type: 'unknown',
label: 'name',
value: null,
icon: 'IconQuestionMark',
},
age: {
isLeaf: true,
type: 'unknown',
label: 'age',
value: null,
icon: 'IconQuestionMark',
},
};
const expected = {
name: null,
age: null,
};
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should handle empty object schema', () => {
const schema: BaseOutputSchema = {};
const expected = {};
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
it('should handle complex nested structure', () => {
const schema: BaseOutputSchema = {
data: {
isLeaf: false,
label: 'data',
value: {
users: {
isLeaf: false,
label: 'users',
value: {
'0': {
isLeaf: false,
label: '0',
value: {
id: {
isLeaf: true,
type: 'number',
label: 'id',
value: 1,
icon: 'IconNumber',
},
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconText',
},
roles: {
isLeaf: false,
label: 'roles',
value: {
'0': {
isLeaf: true,
type: 'string',
label: '0',
value: 'admin',
icon: 'IconText',
},
'1': {
isLeaf: true,
type: 'string',
label: '1',
value: 'user',
icon: 'IconText',
},
},
icon: 'IconBox',
},
settings: {
isLeaf: false,
label: 'settings',
value: {
theme: {
isLeaf: true,
type: 'string',
label: 'theme',
value: 'dark',
icon: 'IconText',
},
notifications: {
isLeaf: true,
type: 'boolean',
label: 'notifications',
value: true,
icon: 'IconCheckbox',
},
},
icon: 'IconBox',
},
},
icon: 'IconBox',
},
},
icon: 'IconBox',
},
metadata: {
isLeaf: false,
label: 'metadata',
value: {
total: {
isLeaf: true,
type: 'number',
label: 'total',
value: 1,
icon: 'IconNumber',
},
page: {
isLeaf: true,
type: 'number',
label: 'page',
value: 1,
icon: 'IconNumber',
},
},
icon: 'IconBox',
},
},
icon: 'IconBox',
},
};
const expected = {
data: {
users: [
{
id: 1,
name: 'John',
roles: ['admin', 'user'],
settings: {
theme: 'dark',
notifications: true,
},
},
],
metadata: {
total: 1,
page: 1,
},
},
};
expect(convertOutputSchemaToJson(schema)).toEqual(expected);
});
});

View File

@ -0,0 +1,327 @@
import { getHttpRequestOutputSchema } from '../getHttpRequestOutputSchema';
describe('getHttpRequestOutputSchema', () => {
it('should return empty object for null input', () => {
expect(getHttpRequestOutputSchema(null)).toEqual({});
});
it('should return empty object for undefined input', () => {
expect(getHttpRequestOutputSchema(undefined)).toEqual({});
});
it('should return empty object for non-object input', () => {
expect(getHttpRequestOutputSchema('string')).toEqual({});
expect(getHttpRequestOutputSchema(123)).toEqual({});
expect(getHttpRequestOutputSchema(true)).toEqual({});
});
it('should handle string values correctly', () => {
const input = { name: 'John', email: 'john@example.com' };
const expected = {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconAbc',
},
email: {
isLeaf: true,
type: 'string',
label: 'email',
value: 'john@example.com',
icon: 'IconAbc',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
it('should handle number values correctly', () => {
const input = { age: 25, score: 98.5 };
const expected = {
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 25,
icon: 'IconText',
},
score: {
isLeaf: true,
type: 'number',
label: 'score',
value: 98.5,
icon: 'IconText',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
it('should handle boolean values correctly', () => {
const input = { isActive: true, isVerified: false };
const expected = {
isActive: {
isLeaf: true,
type: 'boolean',
label: 'isActive',
value: true,
icon: 'IconCheckbox',
},
isVerified: {
isLeaf: true,
type: 'boolean',
label: 'isVerified',
value: false,
icon: 'IconCheckbox',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
it('should handle nested objects correctly', () => {
const input = {
user: {
name: 'John',
age: 25,
address: {
city: 'New York',
country: 'USA',
},
},
};
const expected = {
user: {
isLeaf: false,
label: 'user',
value: {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconAbc',
},
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 25,
icon: 'IconText',
},
address: {
isLeaf: false,
label: 'address',
value: {
city: {
isLeaf: true,
type: 'string',
label: 'city',
value: 'New York',
icon: 'IconAbc',
},
country: {
isLeaf: true,
type: 'string',
label: 'country',
value: 'USA',
icon: 'IconAbc',
},
},
icon: 'IconBox',
},
},
icon: 'IconBox',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
it('should handle arrays correctly', () => {
const input = {
tags: ['tag1', 'tag2'],
scores: [1, 2, 3],
flags: [true, false],
};
const expected = {
tags: {
isLeaf: false,
label: 'tags',
value: {
'0': {
isLeaf: true,
type: 'string',
label: '0',
value: 'tag1',
icon: 'IconAbc',
},
'1': {
isLeaf: true,
type: 'string',
label: '1',
value: 'tag2',
icon: 'IconAbc',
},
},
icon: 'IconBox',
},
scores: {
isLeaf: false,
label: 'scores',
value: {
'0': {
isLeaf: true,
type: 'number',
label: '0',
value: 1,
icon: 'IconText',
},
'1': {
isLeaf: true,
type: 'number',
label: '1',
value: 2,
icon: 'IconText',
},
'2': {
isLeaf: true,
type: 'number',
label: '2',
value: 3,
icon: 'IconText',
},
},
icon: 'IconBox',
},
flags: {
isLeaf: false,
label: 'flags',
value: {
'0': {
isLeaf: true,
type: 'boolean',
label: '0',
value: true,
icon: 'IconCheckbox',
},
'1': {
isLeaf: true,
type: 'boolean',
label: '1',
value: false,
icon: 'IconCheckbox',
},
},
icon: 'IconBox',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
it('should handle null values correctly', () => {
const input = { name: null, age: null };
const expected = {
name: {
isLeaf: true,
type: 'unknown',
label: 'name',
value: null,
icon: 'IconQuestionMark',
},
age: {
isLeaf: true,
type: 'unknown',
label: 'age',
value: null,
icon: 'IconQuestionMark',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
it('should handle mixed type values correctly', () => {
const input = {
name: 'John',
age: 25,
isActive: true,
tags: ['tag1', 'tag2'],
address: {
city: 'New York',
zip: 10001,
},
metadata: null,
};
const expected = {
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'John',
icon: 'IconAbc',
},
age: {
isLeaf: true,
type: 'number',
label: 'age',
value: 25,
icon: 'IconText',
},
isActive: {
isLeaf: true,
type: 'boolean',
label: 'isActive',
value: true,
icon: 'IconCheckbox',
},
tags: {
isLeaf: false,
label: 'tags',
value: {
'0': {
isLeaf: true,
type: 'string',
label: '0',
value: 'tag1',
icon: 'IconAbc',
},
'1': {
isLeaf: true,
type: 'string',
label: '1',
value: 'tag2',
icon: 'IconAbc',
},
},
icon: 'IconBox',
},
address: {
isLeaf: false,
label: 'address',
value: {
city: {
isLeaf: true,
type: 'string',
label: 'city',
value: 'New York',
icon: 'IconAbc',
},
zip: {
isLeaf: true,
type: 'number',
label: 'zip',
value: 10001,
icon: 'IconText',
},
},
icon: 'IconBox',
},
metadata: {
isLeaf: true,
type: 'unknown',
label: 'metadata',
value: null,
icon: 'IconQuestionMark',
},
};
expect(getHttpRequestOutputSchema(input)).toEqual(expected);
});
});

View File

@ -0,0 +1,39 @@
import { HttpRequestBody } from '../../constants/HttpRequest';
import { hasNonStringValues } from '../hasNonStringValues';
describe('hasNonStringValues', () => {
it('should return false for undefined input', () => {
expect(hasNonStringValues(undefined)).toBe(false);
});
it('should return false for empty object', () => {
expect(hasNonStringValues({})).toBe(false);
});
it('should return false for object with only string values', () => {
expect(hasNonStringValues({ key1: 'value1', key2: 'value2' })).toBe(false);
});
it('should return false for object with null values', () => {
const body: HttpRequestBody = { key1: null, key2: 'value' };
expect(hasNonStringValues(body)).toBe(false);
});
it('should return true for object with number values', () => {
const body: HttpRequestBody = { key1: 'value1', key2: 123 };
expect(hasNonStringValues(body)).toBe(true);
});
it('should return true for object with boolean values', () => {
const body: HttpRequestBody = { key1: 'value1', key2: true };
expect(hasNonStringValues(body)).toBe(true);
});
it('should return true for object with array values', () => {
const body: HttpRequestBody = {
key1: 'value1',
key2: [1, 'two', true, null],
};
expect(hasNonStringValues(body)).toBe(true);
});
});

View File

@ -0,0 +1,25 @@
import { HttpMethodWithBody } from '../../constants/HttpRequest';
import { isMethodWithBody } from '../isMethodWithBody';
describe('isMethodWithBody', () => {
it('should return false for null input', () => {
expect(isMethodWithBody(null)).toBe(false);
});
it('should return false for undefined input', () => {
expect(isMethodWithBody(undefined as unknown as string)).toBe(false);
});
it('should return false for non-body methods', () => {
expect(isMethodWithBody('GET')).toBe(false);
expect(isMethodWithBody('HEAD')).toBe(false);
expect(isMethodWithBody('OPTIONS')).toBe(false);
});
it('should return true for methods with body', () => {
const methodsWithBody: HttpMethodWithBody[] = ['POST', 'PUT', 'PATCH'];
methodsWithBody.forEach((method) => {
expect(isMethodWithBody(method)).toBe(true);
});
});
});

View File

@ -0,0 +1,35 @@
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
export const convertOutputSchemaToJson = (
schema: BaseOutputSchema,
): Record<string, unknown> | unknown[] => {
const keys = Object.keys(schema);
if (keys.length === 0) {
return {};
}
const isArray = keys.every((key, index) => key === String(index));
if (isArray) {
return keys.map((key) => {
const entry = schema[key];
if (entry.isLeaf) {
return entry.value;
}
return convertOutputSchemaToJson(entry.value as BaseOutputSchema);
});
}
const result: Record<string, unknown> = {};
Object.entries(schema).forEach(([key, entry]) => {
if (entry.isLeaf) {
result[key] = entry.value;
} else {
result[key] = convertOutputSchemaToJson(entry.value as BaseOutputSchema);
}
});
return result;
};

View File

@ -0,0 +1,87 @@
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
const getValueType = (value: unknown): InputSchemaPropertyType => {
if (value === null || value === undefined) {
return 'unknown';
}
if (typeof value === 'string') {
return 'string';
}
if (typeof value === 'number') {
return 'number';
}
if (typeof value === 'boolean') {
return 'boolean';
}
if (Array.isArray(value)) {
return 'array';
}
if (typeof value === 'object') {
return 'object';
}
return 'unknown';
};
export const getHttpRequestOutputSchema = (
responseData: unknown,
): BaseOutputSchema => {
if (typeof responseData !== 'object' || responseData === null) {
return {};
}
const schema: BaseOutputSchema = {};
Object.entries(responseData).forEach(([key, value]) => {
const type = getValueType(value);
switch (type) {
case 'string':
schema[key] = {
isLeaf: true,
type: 'string',
label: key,
value,
icon: 'IconAbc',
};
break;
case 'number':
schema[key] = {
isLeaf: true,
type: 'number',
label: key,
value,
icon: 'IconText',
};
break;
case 'boolean':
schema[key] = {
isLeaf: true,
type: 'boolean',
label: key,
value,
icon: 'IconCheckbox',
};
break;
case 'array':
case 'object':
schema[key] = {
isLeaf: false,
label: key,
value: getHttpRequestOutputSchema(value),
icon: 'IconBox',
};
break;
case 'unknown':
default:
schema[key] = {
isLeaf: true,
type: 'unknown',
label: key,
value: value === null ? null : String(value),
icon: 'IconQuestionMark',
};
break;
}
});
return schema;
};

View File

@ -0,0 +1,11 @@
import { HttpRequestBody } from '../constants/HttpRequest';
export const hasNonStringValues = (obj?: HttpRequestBody): boolean => {
if (!obj) {
return false;
}
return Object.values(obj).some(
(value) =>
value !== null && value !== undefined && typeof value !== 'string',
);
};

View File

@ -0,0 +1,14 @@
import {
HttpMethodWithBody,
METHODS_WITH_BODY,
} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
export const isMethodWithBody = (
method: string | null,
): method is HttpMethodWithBody => {
if (!method) {
return false;
}
return METHODS_WITH_BODY.includes(method as HttpMethodWithBody);
};

View File

@ -0,0 +1,41 @@
import { getActionHeaderTypeOrThrow } from '../getActionHeaderTypeOrThrow';
describe('getActionHeaderTypeOrThrow', () => {
it('should return "Code" for CODE action type', () => {
expect(getActionHeaderTypeOrThrow('CODE').message).toBe('Code');
});
it('should return "Action" for record-related action types', () => {
const recordActionTypes = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
] as const;
recordActionTypes.forEach((type) => {
expect(getActionHeaderTypeOrThrow(type).message).toBe('Action');
});
});
it('should return "Action" for FORM action type', () => {
expect(getActionHeaderTypeOrThrow('FORM').message).toBe('Action');
});
it('should return "Action" for SEND_EMAIL action type', () => {
expect(getActionHeaderTypeOrThrow('SEND_EMAIL').message).toBe('Action');
});
it('should return "HTTP Request" for HTTP_REQUEST action type', () => {
expect(getActionHeaderTypeOrThrow('HTTP_REQUEST').message).toBe(
'HTTP Request',
);
});
it('should throw error for unknown action type', () => {
// @ts-expect-error Testing invalid action type
expect(() => getActionHeaderTypeOrThrow('UNKNOWN_ACTION')).toThrow(
'Unsupported action type: UNKNOWN_ACTION',
);
});
});

View File

@ -0,0 +1,20 @@
import { OTHER_ACTIONS } from '../../constants/OtherActions';
import { RECORD_ACTIONS } from '../../constants/RecordActions';
import { getActionIcon } from '../getActionIcon';
describe('getActionIcon', () => {
it('should return correct icon for all action types', () => {
RECORD_ACTIONS.forEach((action) => {
expect(getActionIcon(action.type)).toBe(action.icon);
});
OTHER_ACTIONS.forEach((action) => {
expect(getActionIcon(action.type)).toBe(action.icon);
});
});
it('should return undefined for unknown action type', () => {
// @ts-expect-error Testing invalid action type
expect(getActionIcon('UNKNOWN_ACTION')).toBeUndefined();
});
});

View File

@ -11,9 +11,10 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
case 'DELETE_RECORD':
case 'FIND_RECORDS':
case 'FORM':
return msg`Action`;
case 'SEND_EMAIL':
return msg`Email`;
return msg`Action`;
case 'HTTP_REQUEST':
return msg`HTTP Request`;
default:
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);
}

View File

@ -11,6 +11,7 @@ export const getActionIconColorOrThrow = ({
}) => {
switch (actionType) {
case 'CODE':
case 'HTTP_REQUEST':
return theme.color.orange;
case 'CREATE_RECORD':
case 'UPDATE_RECORD':

View File

@ -1,4 +1,4 @@
import { IconComponent, IconHttpPost, IconHttpGet } from 'twenty-ui/display';
import { IconComponent, IconHttpGet, IconHttpPost } from 'twenty-ui/display';
export type WebhookHttpMethods = 'GET' | 'POST';
export const WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS: Array<{
@ -7,12 +7,12 @@ export const WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS: Array<{
Icon: IconComponent;
}> = [
{
label: 'Get',
label: 'GET',
value: 'GET',
Icon: IconHttpGet,
},
{
label: 'Post',
label: 'POST',
value: 'POST',
Icon: IconHttpPost,
},

View File

@ -348,8 +348,14 @@ export class WorkflowVersionStepWorkspaceService {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
// We don't enrich on the fly for code workflow action. OutputSchema is computed and updated when testing the serverless function
if (step.type === WorkflowActionType.CODE) {
// We don't enrich on the fly for code and HTTP request workflow actions.
// For code actions, OutputSchema is computed and updated when testing the serverless function.
// For HTTP requests, OutputSchema is determined by the expamle response input
if (
[WorkflowActionType.CODE, WorkflowActionType.HTTP_REQUEST].includes(
step.type,
)
) {
return step;
}
@ -555,6 +561,23 @@ export class WorkflowVersionStepWorkspaceService {
},
};
}
case WorkflowActionType.HTTP_REQUEST: {
return {
id: newStepId,
name: 'HTTP Request',
type: WorkflowActionType.HTTP_REQUEST,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
url: '',
method: 'GET',
headers: {},
body: {},
},
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,

View File

@ -9,6 +9,7 @@ import {
import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action';
import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action';
import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action';
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
@ -27,6 +28,7 @@ export class WorkflowExecutorFactory {
private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction,
private readonly formWorkflowAction: FormWorkflowAction,
private readonly filterWorkflowAction: FilterWorkflowAction,
private readonly httpRequestWorkflowAction: HttpRequestWorkflowAction,
) {}
get(stepType: WorkflowActionType): WorkflowExecutor {
@ -47,6 +49,8 @@ export class WorkflowExecutorFactory {
return this.formWorkflowAction;
case WorkflowActionType.FILTER:
return this.filterWorkflowAction;
case WorkflowActionType.HTTP_REQUEST:
return this.httpRequestWorkflowAction;
default:
throw new WorkflowStepExecutorException(
`Workflow step executor not found for step type '${stepType}'`,

View File

@ -44,16 +44,18 @@ const resolveObject = (
input: object,
context: Record<string, unknown>,
): object => {
const resolvedObject = input;
return Object.entries(input).reduce<Record<string, unknown>>(
(resolvedObject, [key, value]) => {
const resolvedKey = resolveInput(key, context);
const entries = Object.entries(resolvedObject);
resolvedObject[
typeof resolvedKey === 'string' ? resolvedKey : String(resolvedKey)
] = resolveInput(value, context);
for (const [key, value] of entries) {
// @ts-expect-error legacy noImplicitAny
resolvedObject[key] = resolveInput(value, context);
}
return resolvedObject;
return resolvedObject;
},
{},
);
};
const resolveString = (

View File

@ -0,0 +1,11 @@
import {
WorkflowAction,
WorkflowActionType,
WorkflowHttpRequestAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowHttpRequestAction = (
action: WorkflowAction,
): action is WorkflowHttpRequestAction => {
return action.type === WorkflowActionType.HTTP_REQUEST;
};

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HttpRequestWorkflowAction } from './http-request.workflow-action';
@Module({
providers: [HttpRequestWorkflowAction],
exports: [HttpRequestWorkflowAction],
})
export class HttpRequestActionModule {}

View File

@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import axios, { AxiosRequestConfig } from 'axios';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowHttpRequestAction } from './guards/is-workflow-http-request-action.guard';
import { WorkflowHttpRequestActionInput } from './types/workflow-http-request-action-input.type';
@Injectable()
export class HttpRequestWorkflowAction implements WorkflowExecutor {
async execute({
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
throw new WorkflowStepExecutorException(
'Step not found',
WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND,
);
}
if (!isWorkflowHttpRequestAction(step)) {
throw new WorkflowStepExecutorException(
'Step is not an HTTP Request action',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const workflowActionInput = resolveInput(
step.settings.input,
context,
) as WorkflowHttpRequestActionInput;
const { url, method, headers, body } = workflowActionInput;
try {
const axiosConfig: AxiosRequestConfig = {
url,
method: method,
headers,
};
if (['POST', 'PUT', 'PATCH'].includes(method) && body) {
axiosConfig.data = body;
}
const response = await axios(axiosConfig);
return { result: response.data };
} catch (error) {
if (axios.isAxiosError(error)) {
return {
error: error.response?.data || error.message || 'HTTP request failed',
};
}
return {
error: error instanceof Error ? error.message : 'HTTP request failed',
};
}
}
}

View File

@ -0,0 +1,14 @@
export type WorkflowHttpRequestActionInput = {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
headers?: Record<string, string>;
body?: Record<
string,
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null>
>;
};

View File

@ -0,0 +1,7 @@
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import { WorkflowHttpRequestActionInput } from './workflow-http-request-action-input.type';
export type WorkflowHttpRequestActionSettings = BaseWorkflowActionSettings & {
input: WorkflowHttpRequestActionInput;
};

View File

@ -2,6 +2,7 @@ import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-sch
import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import { WorkflowHttpRequestActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type';
import { WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type';
import {
WorkflowCreateRecordActionSettings,
@ -30,4 +31,5 @@ export type WorkflowActionSettings =
| WorkflowDeleteRecordActionSettings
| WorkflowFindRecordsActionSettings
| WorkflowFormActionSettings
| WorkflowFilterActionSettings;
| WorkflowFilterActionSettings
| WorkflowHttpRequestActionSettings;

View File

@ -1,6 +1,7 @@
import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import { WorkflowHttpRequestActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type';
import { WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type';
import {
WorkflowCreateRecordActionSettings,
@ -19,6 +20,7 @@ export enum WorkflowActionType {
FIND_RECORDS = 'FIND_RECORDS',
FORM = 'FORM',
FILTER = 'FILTER',
HTTP_REQUEST = 'HTTP_REQUEST',
}
type BaseWorkflowAction = {
@ -70,6 +72,11 @@ export type WorkflowFilterAction = BaseWorkflowAction & {
settings: WorkflowFilterActionSettings;
};
export type WorkflowHttpRequestAction = BaseWorkflowAction & {
type: WorkflowActionType.HTTP_REQUEST;
settings: WorkflowHttpRequestActionSettings;
};
export type WorkflowAction =
| WorkflowCodeAction
| WorkflowSendEmailAction
@ -78,4 +85,5 @@ export type WorkflowAction =
| WorkflowDeleteRecordAction
| WorkflowFindRecordsAction
| WorkflowFormAction
| WorkflowFilterAction;
| WorkflowFilterAction
| WorkflowHttpRequestAction;

View File

@ -7,6 +7,7 @@ import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/
import { CodeActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module';
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module';
import { HttpRequestActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request-action.module';
import { SendEmailActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email-action.module';
import { RecordCRUDActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module';
import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service';
@ -22,6 +23,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
WorkflowRunModule,
BillingModule,
FilterActionModule,
HttpRequestActionModule,
],
providers: [
WorkflowExecutorWorkspaceService,