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:
@ -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'];
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -23,4 +23,9 @@ export const OTHER_ACTIONS: Array<{
|
||||
type: 'FORM',
|
||||
icon: 'IconForms',
|
||||
},
|
||||
{
|
||||
label: 'HTTP Request',
|
||||
type: 'HTTP_REQUEST',
|
||||
icon: 'IconWorld',
|
||||
},
|
||||
];
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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}';
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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',
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ export const getActionIconColorOrThrow = ({
|
||||
}) => {
|
||||
switch (actionType) {
|
||||
case 'CODE':
|
||||
case 'HTTP_REQUEST':
|
||||
return theme.color.orange;
|
||||
case 'CREATE_RECORD':
|
||||
case 'UPDATE_RECORD':
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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}'`,
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HttpRequestWorkflowAction } from './http-request.workflow-action';
|
||||
|
||||
@Module({
|
||||
providers: [HttpRequestWorkflowAction],
|
||||
exports: [HttpRequestWorkflowAction],
|
||||
})
|
||||
export class HttpRequestActionModule {}
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
>;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user