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,
|
workflowFindRecordsActionSettingsSchema,
|
||||||
workflowFormActionSchema,
|
workflowFormActionSchema,
|
||||||
workflowFormActionSettingsSchema,
|
workflowFormActionSettingsSchema,
|
||||||
|
workflowHttpRequestActionSchema,
|
||||||
workflowManualTriggerSchema,
|
workflowManualTriggerSchema,
|
||||||
workflowRunContextSchema,
|
workflowRunContextSchema,
|
||||||
workflowRunOutputSchema,
|
workflowRunOutputSchema,
|
||||||
@ -67,6 +68,9 @@ export type WorkflowFindRecordsAction = z.infer<
|
|||||||
typeof workflowFindRecordsActionSchema
|
typeof workflowFindRecordsActionSchema
|
||||||
>;
|
>;
|
||||||
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
|
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
|
||||||
|
export type WorkflowHttpRequestAction = z.infer<
|
||||||
|
typeof workflowHttpRequestActionSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export type WorkflowAction = z.infer<typeof workflowActionSchema>;
|
export type WorkflowAction = z.infer<typeof workflowActionSchema>;
|
||||||
export type WorkflowActionType = WorkflowAction['type'];
|
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
|
// Action schemas
|
||||||
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
|
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
|
||||||
type: z.literal('CODE'),
|
type: z.literal('CODE'),
|
||||||
@ -152,6 +172,11 @@ export const workflowFormActionSchema = baseWorkflowActionSchema.extend({
|
|||||||
settings: workflowFormActionSettingsSchema,
|
settings: workflowFormActionSettingsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const workflowHttpRequestActionSchema = baseWorkflowActionSchema.extend({
|
||||||
|
type: z.literal('HTTP_REQUEST'),
|
||||||
|
settings: workflowHttpRequestActionSettingsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
// Combined action schema
|
// Combined action schema
|
||||||
export const workflowActionSchema = z.discriminatedUnion('type', [
|
export const workflowActionSchema = z.discriminatedUnion('type', [
|
||||||
workflowCodeActionSchema,
|
workflowCodeActionSchema,
|
||||||
@ -161,6 +186,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
|
|||||||
workflowDeleteRecordActionSchema,
|
workflowDeleteRecordActionSchema,
|
||||||
workflowFindRecordsActionSchema,
|
workflowFindRecordsActionSchema,
|
||||||
workflowFormActionSchema,
|
workflowFormActionSchema,
|
||||||
|
workflowHttpRequestActionSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Trigger schemas
|
// Trigger schemas
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||||
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
|
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
|
||||||
@ -46,7 +45,8 @@ export const WorkflowDiagramStepNodeIcon = ({
|
|||||||
}
|
}
|
||||||
case 'action': {
|
case 'action': {
|
||||||
switch (data.actionType) {
|
switch (data.actionType) {
|
||||||
case 'CODE': {
|
case 'CODE':
|
||||||
|
case 'HTTP_REQUEST': {
|
||||||
return (
|
return (
|
||||||
<StyledStepNodeLabelIconContainer>
|
<StyledStepNodeLabelIconContainer>
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@ -68,6 +68,11 @@ const ALL_STEPS = [
|
|||||||
actionType: 'CODE',
|
actionType: 'CODE',
|
||||||
name: 'Code',
|
name: 'Code',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'action',
|
||||||
|
actionType: 'HTTP_REQUEST',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
},
|
||||||
] satisfies WorkflowDiagramStepNodeData[];
|
] satisfies WorkflowDiagramStepNodeData[];
|
||||||
|
|
||||||
export const Catalog: CatalogStory<Story, typeof Wrapper> = {
|
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 { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
|
||||||
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
|
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 { 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 { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
|
||||||
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
||||||
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
|
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 { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
|
||||||
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
|
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 { 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 { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
|
||||||
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
||||||
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
|
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 { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
|
||||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||||
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
|
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||||
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
|
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
|
||||||
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
|
|
||||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
@ -17,7 +16,6 @@ import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
|
|||||||
import { SelectOption } from 'twenty-ui/input';
|
import { SelectOption } from 'twenty-ui/input';
|
||||||
import { JsonValue } from 'type-fest';
|
import { JsonValue } from 'type-fest';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
|
|
||||||
|
|
||||||
type WorkflowEditActionCreateRecordProps = {
|
type WorkflowEditActionCreateRecordProps = {
|
||||||
action: WorkflowCreateRecordAction;
|
action: WorkflowCreateRecordAction;
|
||||||
@ -159,10 +157,11 @@ export const WorkflowEditActionCreateRecord = ({
|
|||||||
};
|
};
|
||||||
}, [saveAction]);
|
}, [saveAction]);
|
||||||
|
|
||||||
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
|
const { headerTitle, headerIcon, headerIconColor, headerType } =
|
||||||
const headerIcon = getActionIcon(action.type);
|
useWorkflowActionHeader({
|
||||||
const headerIconColor = useActionIconColorOrThrow(action.type);
|
action,
|
||||||
const headerType = useActionHeaderTypeOrThrow(action.type);
|
defaultTitle: 'Create Record',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||||
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
|
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||||
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
|
|
||||||
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
|
|
||||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
|
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
|
||||||
@ -104,10 +102,11 @@ export const WorkflowEditActionDeleteRecord = ({
|
|||||||
};
|
};
|
||||||
}, [saveAction]);
|
}, [saveAction]);
|
||||||
|
|
||||||
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
|
const { headerTitle, headerIcon, headerIconColor, headerType } =
|
||||||
const headerIcon = getActionIcon(action.type);
|
useWorkflowActionHeader({
|
||||||
const headerIconColor = useActionIconColorOrThrow(action.type);
|
action,
|
||||||
const headerType = useActionHeaderTypeOrThrow(action.type);
|
defaultTitle: 'Delete Record',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -13,9 +13,7 @@ import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/wo
|
|||||||
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||||
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
|
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||||
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
|
|
||||||
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
|
|
||||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
@ -197,10 +195,11 @@ export const WorkflowEditActionSendEmail = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
|
const { headerTitle, headerIcon, headerIconColor, headerType } =
|
||||||
const headerIcon = getActionIcon(action.type);
|
useWorkflowActionHeader({
|
||||||
const headerIconColor = useActionIconColorOrThrow(action.type);
|
action,
|
||||||
const headerType = useActionHeaderTypeOrThrow(action.type);
|
defaultTitle: 'Send Email',
|
||||||
|
});
|
||||||
|
|
||||||
const navigate = useNavigateSettings();
|
const navigate = useNavigateSettings();
|
||||||
|
|
||||||
|
|||||||
@ -10,9 +10,7 @@ import { isFieldRelation } from '@/object-record/record-field/types/guards/isFie
|
|||||||
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
|
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
|
||||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||||
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
|
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||||
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
|
|
||||||
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
|
|
||||||
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
|
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
|
||||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
@ -139,10 +137,11 @@ export const WorkflowEditActionUpdateRecord = ({
|
|||||||
};
|
};
|
||||||
}, [saveAction]);
|
}, [saveAction]);
|
||||||
|
|
||||||
const headerTitle = isDefined(action.name) ? action.name : `Update Record`;
|
const { headerTitle, headerIcon, headerIconColor, headerType } =
|
||||||
const headerIcon = getActionIcon(action.type);
|
useWorkflowActionHeader({
|
||||||
const headerIconColor = useActionIconColorOrThrow(action.type);
|
action,
|
||||||
const headerType = useActionHeaderTypeOrThrow(action.type);
|
defaultTitle: 'Update Record',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -23,4 +23,9 @@ export const OTHER_ACTIONS: Array<{
|
|||||||
type: 'FORM',
|
type: 'FORM',
|
||||||
icon: 'IconForms',
|
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 { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||||
import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters';
|
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 { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect';
|
||||||
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
|
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||||
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
|
|
||||||
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
|
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
|
||||||
@ -115,10 +113,11 @@ export const WorkflowEditActionFindRecords = ({
|
|||||||
};
|
};
|
||||||
}, [saveAction]);
|
}, [saveAction]);
|
||||||
|
|
||||||
const headerTitle = isDefined(action.name) ? action.name : `Search Records`;
|
const { headerTitle, headerIcon, headerIconColor, headerType } =
|
||||||
const headerIcon = getActionIcon(action.type);
|
useWorkflowActionHeader({
|
||||||
const headerIconColor = useActionIconColorOrThrow(action.type);
|
action,
|
||||||
const headerType = useActionHeaderTypeOrThrow(action.type);
|
defaultTitle: 'Search Records',
|
||||||
|
});
|
||||||
const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectName}`;
|
const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectName}`;
|
||||||
|
|
||||||
return (
|
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 'DELETE_RECORD':
|
||||||
case 'FIND_RECORDS':
|
case 'FIND_RECORDS':
|
||||||
case 'FORM':
|
case 'FORM':
|
||||||
return msg`Action`;
|
|
||||||
case 'SEND_EMAIL':
|
case 'SEND_EMAIL':
|
||||||
return msg`Email`;
|
return msg`Action`;
|
||||||
|
case 'HTTP_REQUEST':
|
||||||
|
return msg`HTTP Request`;
|
||||||
default:
|
default:
|
||||||
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);
|
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export const getActionIconColorOrThrow = ({
|
|||||||
}) => {
|
}) => {
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
case 'CODE':
|
case 'CODE':
|
||||||
|
case 'HTTP_REQUEST':
|
||||||
return theme.color.orange;
|
return theme.color.orange;
|
||||||
case 'CREATE_RECORD':
|
case 'CREATE_RECORD':
|
||||||
case 'UPDATE_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 type WebhookHttpMethods = 'GET' | 'POST';
|
||||||
|
|
||||||
export const WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS: Array<{
|
export const WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS: Array<{
|
||||||
@ -7,12 +7,12 @@ export const WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS: Array<{
|
|||||||
Icon: IconComponent;
|
Icon: IconComponent;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
label: 'Get',
|
label: 'GET',
|
||||||
value: 'GET',
|
value: 'GET',
|
||||||
Icon: IconHttpGet,
|
Icon: IconHttpGet,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Post',
|
label: 'POST',
|
||||||
value: 'POST',
|
value: 'POST',
|
||||||
Icon: IconHttpPost,
|
Icon: IconHttpPost,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -348,8 +348,14 @@ export class WorkflowVersionStepWorkspaceService {
|
|||||||
step: WorkflowAction;
|
step: WorkflowAction;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}): Promise<WorkflowAction> {
|
}): Promise<WorkflowAction> {
|
||||||
// We don't enrich on the fly for code workflow action. OutputSchema is computed and updated when testing the serverless function
|
// We don't enrich on the fly for code and HTTP request workflow actions.
|
||||||
if (step.type === WorkflowActionType.CODE) {
|
// 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;
|
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:
|
default:
|
||||||
throw new WorkflowVersionStepException(
|
throw new WorkflowVersionStepException(
|
||||||
`WorkflowActionType '${type}' unknown`,
|
`WorkflowActionType '${type}' unknown`,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action';
|
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 { 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 { 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 { 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 { 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';
|
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 findRecordsWorkflowAction: FindRecordsWorkflowAction,
|
||||||
private readonly formWorkflowAction: FormWorkflowAction,
|
private readonly formWorkflowAction: FormWorkflowAction,
|
||||||
private readonly filterWorkflowAction: FilterWorkflowAction,
|
private readonly filterWorkflowAction: FilterWorkflowAction,
|
||||||
|
private readonly httpRequestWorkflowAction: HttpRequestWorkflowAction,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(stepType: WorkflowActionType): WorkflowExecutor {
|
get(stepType: WorkflowActionType): WorkflowExecutor {
|
||||||
@ -47,6 +49,8 @@ export class WorkflowExecutorFactory {
|
|||||||
return this.formWorkflowAction;
|
return this.formWorkflowAction;
|
||||||
case WorkflowActionType.FILTER:
|
case WorkflowActionType.FILTER:
|
||||||
return this.filterWorkflowAction;
|
return this.filterWorkflowAction;
|
||||||
|
case WorkflowActionType.HTTP_REQUEST:
|
||||||
|
return this.httpRequestWorkflowAction;
|
||||||
default:
|
default:
|
||||||
throw new WorkflowStepExecutorException(
|
throw new WorkflowStepExecutorException(
|
||||||
`Workflow step executor not found for step type '${stepType}'`,
|
`Workflow step executor not found for step type '${stepType}'`,
|
||||||
|
|||||||
@ -44,16 +44,18 @@ const resolveObject = (
|
|||||||
input: object,
|
input: object,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
): object => {
|
): 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) {
|
return resolvedObject;
|
||||||
// @ts-expect-error legacy noImplicitAny
|
},
|
||||||
resolvedObject[key] = resolveInput(value, context);
|
{},
|
||||||
}
|
);
|
||||||
|
|
||||||
return resolvedObject;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveString = (
|
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 { 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 { 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 { 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 { WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type';
|
||||||
import {
|
import {
|
||||||
WorkflowCreateRecordActionSettings,
|
WorkflowCreateRecordActionSettings,
|
||||||
@ -30,4 +31,5 @@ export type WorkflowActionSettings =
|
|||||||
| WorkflowDeleteRecordActionSettings
|
| WorkflowDeleteRecordActionSettings
|
||||||
| WorkflowFindRecordsActionSettings
|
| WorkflowFindRecordsActionSettings
|
||||||
| WorkflowFormActionSettings
|
| 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 { 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 { 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 { 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 { WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type';
|
||||||
import {
|
import {
|
||||||
WorkflowCreateRecordActionSettings,
|
WorkflowCreateRecordActionSettings,
|
||||||
@ -19,6 +20,7 @@ export enum WorkflowActionType {
|
|||||||
FIND_RECORDS = 'FIND_RECORDS',
|
FIND_RECORDS = 'FIND_RECORDS',
|
||||||
FORM = 'FORM',
|
FORM = 'FORM',
|
||||||
FILTER = 'FILTER',
|
FILTER = 'FILTER',
|
||||||
|
HTTP_REQUEST = 'HTTP_REQUEST',
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseWorkflowAction = {
|
type BaseWorkflowAction = {
|
||||||
@ -70,6 +72,11 @@ export type WorkflowFilterAction = BaseWorkflowAction & {
|
|||||||
settings: WorkflowFilterActionSettings;
|
settings: WorkflowFilterActionSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkflowHttpRequestAction = BaseWorkflowAction & {
|
||||||
|
type: WorkflowActionType.HTTP_REQUEST;
|
||||||
|
settings: WorkflowHttpRequestActionSettings;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowAction =
|
export type WorkflowAction =
|
||||||
| WorkflowCodeAction
|
| WorkflowCodeAction
|
||||||
| WorkflowSendEmailAction
|
| WorkflowSendEmailAction
|
||||||
@ -78,4 +85,5 @@ export type WorkflowAction =
|
|||||||
| WorkflowDeleteRecordAction
|
| WorkflowDeleteRecordAction
|
||||||
| WorkflowFindRecordsAction
|
| WorkflowFindRecordsAction
|
||||||
| WorkflowFormAction
|
| 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 { 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 { 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 { 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 { 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 { 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';
|
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,
|
WorkflowRunModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
FilterActionModule,
|
FilterActionModule,
|
||||||
|
HttpRequestActionModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WorkflowExecutorWorkspaceService,
|
WorkflowExecutorWorkspaceService,
|
||||||
|
|||||||
Reference in New Issue
Block a user