Introduce a TAB to test the HTTP node (#13622)

<img width="1020" height="757" alt="Screenshot 2025-08-05 at 11 05 12"
src="https://github.com/user-attachments/assets/7cfcb216-d34d-4daa-b468-8153869c8f6a"
/>
This commit is contained in:
Félix Malfait 2025-08-06 12:12:13 +02:00 committed by GitHub
parent de802b1447
commit b7022202fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1799 additions and 169 deletions

View file

@ -45,7 +45,7 @@ const RelationFieldValueSetterEffect = () => {
};
const meta: Meta = {
title: 'RecordIndex/Table/RecordTableCell',
title: 'Modules/ObjectRecord/RecordTable/RecordTableCell',
decorators: [
MemoryRouterDecorator,
ChipGeneratorsDecorator,

View file

@ -1,5 +1,5 @@
import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector';
import { RECORD_TABLE_TD_WIDTH } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { TABLE_CELL_CHECKBOX_MIN_WIDTH } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { RecordTableColumnAggregateFooterValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue';
import { hasAggregateOperationForViewFieldFamilySelector } from '@/object-record/record-table/record-table-footer/states/hasAggregateOperationForViewFieldFamilySelector';
@ -35,10 +35,10 @@ const StyledCell = styled.div<{ isUnfolded: boolean; isFirstCell: boolean }>`
: theme.background.transparent.light};
}
${({ isFirstCell }) =>
${({ isFirstCell, theme }) =>
isFirstCell &&
`
padding-left: ${RECORD_TABLE_TD_WIDTH};
padding-left: calc(${TABLE_CELL_CHECKBOX_MIN_WIDTH} + ${theme.spacing(1)});
`}
`;

View file

@ -1,40 +1,9 @@
import styled from '@emotion/styled';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ServerlessFunctionTestData } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { useTheme } from '@emotion/react';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
import { CodeEditor, CoreEditorHeader } from 'twenty-ui/input';
import {
IconSquareRoundedCheck,
IconSquareRoundedX,
IconLoader,
} from 'twenty-ui/display';
import { AnimatedCircleLoading } from 'twenty-ui/utilities';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
type OutputAccent = 'default' | 'success' | 'error';
const StyledInfoContainer = styled.div`
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledOutput = styled.div<{ accent?: OutputAccent }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme, accent }) =>
accent === 'success'
? theme.color.turquoise
: accent === 'error'
? theme.color.red
: theme.font.color.secondary};
display: flex;
`;
ExecutionStatus,
WorkflowExecutionResult,
} from '@/workflow/components/WorkflowExecutionResult';
import { ServerlessFunctionTestData } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
export const ServerlessFunctionExecutionResult = ({
serverlessFunctionTestData,
@ -43,71 +12,39 @@ export const ServerlessFunctionExecutionResult = ({
serverlessFunctionTestData: ServerlessFunctionTestData;
isTesting?: boolean;
}) => {
const theme = useTheme();
const result =
serverlessFunctionTestData.output.data ||
serverlessFunctionTestData.output.error ||
'';
const SuccessLeftNode = (
<StyledOutput accent="success">
<IconSquareRoundedCheck size={theme.icon.size.md} />
200 OK - {serverlessFunctionTestData.output.duration}ms
</StyledOutput>
);
const isSuccess =
serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.SUCCESS;
const ErrorLeftNode = (
<StyledOutput accent="error">
<IconSquareRoundedX size={theme.icon.size.md} />
500 Error - {serverlessFunctionTestData.output.duration}ms
</StyledOutput>
);
const isError =
serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.ERROR;
const IdleLeftNode = 'Output';
const PendingLeftNode = isTesting && (
<StyledOutput>
<AnimatedCircleLoading>
<IconLoader size={theme.icon.size.md} />
</AnimatedCircleLoading>
<StyledInfoContainer>Running function</StyledInfoContainer>
</StyledOutput>
);
const computeLeftNode = () => {
if (isTesting) {
return PendingLeftNode;
}
if (
serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.ERROR
) {
return ErrorLeftNode;
}
if (
serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.SUCCESS
) {
return SuccessLeftNode;
}
return IdleLeftNode;
const status: ExecutionStatus = {
isSuccess,
isError,
successMessage: isSuccess
? `200 OK - ${serverlessFunctionTestData.output.duration}ms`
: undefined,
errorMessage: isError
? `500 Error - ${serverlessFunctionTestData.output.duration}ms`
: undefined,
};
return (
<StyledContainer>
<CoreEditorHeader
leftNodes={[computeLeftNode()]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<CodeEditor
value={result}
language={serverlessFunctionTestData.language}
height={serverlessFunctionTestData.height}
options={{ readOnly: true, domReadOnly: true }}
isLoading={isTesting}
variant="with-header"
/>
</StyledContainer>
<WorkflowExecutionResult
result={result}
language={serverlessFunctionTestData.language}
height={serverlessFunctionTestData.height}
status={status}
isTesting={isTesting}
loadingMessage="Running function"
idleMessage="Output"
/>
);
};

View file

@ -0,0 +1,147 @@
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
IconLoader,
IconSquareRoundedCheck,
IconSquareRoundedX,
} from 'twenty-ui/display';
import { CodeEditor, CoreEditorHeader } from 'twenty-ui/input';
import { AnimatedCircleLoading } from 'twenty-ui/utilities';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 200px;
`;
const StyledCodeEditorWrapper = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 200px;
`;
type OutputAccent = 'default' | 'success' | 'error';
const StyledInfoContainer = styled.div`
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledOutput = styled.div<{ accent?: OutputAccent }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme, accent }) =>
accent === 'success'
? theme.color.turquoise
: accent === 'error'
? theme.color.red
: theme.font.color.secondary};
display: flex;
`;
const StyledStatusInfo = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
font-size: ${({ theme }) => theme.font.size.sm};
color: ${({ theme }) => theme.font.color.tertiary};
`;
export type ExecutionStatus = {
isSuccess: boolean;
isError: boolean;
successMessage?: string;
errorMessage?: string;
additionalInfo?: string;
};
type WorkflowExecutionResultProps = {
result: string;
language: 'plaintext' | 'json';
height?: string | number;
status: ExecutionStatus;
isTesting?: boolean;
loadingMessage?: string;
idleMessage?: string;
};
export const WorkflowExecutionResult = ({
result,
language,
height = '100%',
status,
isTesting = false,
loadingMessage = 'Processing...',
idleMessage = 'Output',
}: WorkflowExecutionResultProps) => {
const theme = useTheme();
const SuccessLeftNode = (
<StyledOutput accent="success">
<IconSquareRoundedCheck size={theme.icon.size.md} />
<div>
<div>{status.successMessage}</div>
{status.additionalInfo && (
<StyledStatusInfo>{status.additionalInfo}</StyledStatusInfo>
)}
</div>
</StyledOutput>
);
const ErrorLeftNode = (
<StyledOutput accent="error">
<IconSquareRoundedX size={theme.icon.size.md} />
<div>
<div>{status.errorMessage}</div>
{status.additionalInfo && (
<StyledStatusInfo>{status.additionalInfo}</StyledStatusInfo>
)}
</div>
</StyledOutput>
);
const IdleLeftNode = idleMessage;
const PendingLeftNode = isTesting && (
<StyledOutput>
<AnimatedCircleLoading>
<IconLoader size={theme.icon.size.md} />
</AnimatedCircleLoading>
<StyledInfoContainer>{loadingMessage}</StyledInfoContainer>
</StyledOutput>
);
const computeLeftNode = () => {
if (isTesting) {
return PendingLeftNode;
}
if (status.isError) {
return ErrorLeftNode;
}
if (status.isSuccess) {
return SuccessLeftNode;
}
return IdleLeftNode;
};
return (
<StyledContainer>
<CoreEditorHeader
leftNodes={[computeLeftNode()]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<StyledCodeEditorWrapper>
<CodeEditor
value={result}
language={language}
height={height}
options={{ readOnly: true, domReadOnly: true }}
isLoading={isTesting}
variant="with-header"
/>
</StyledCodeEditorWrapper>
</StyledContainer>
);
};

View file

@ -0,0 +1,196 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowExecutionResult } from '@/workflow/components/WorkflowExecutionResult';
const meta: Meta<typeof WorkflowExecutionResult> = {
title: 'Modules/Workflow/Components/ExecutionResult',
component: WorkflowExecutionResult,
decorators: [ComponentDecorator, SnackBarDecorator, I18nFrontDecorator],
args: {
result: JSON.stringify(
{ message: 'Hello World', status: 'success' },
null,
2,
),
language: 'json',
height: '300px',
status: {
isSuccess: false,
isError: false,
},
isTesting: false,
loadingMessage: 'Processing...',
idleMessage: 'Output',
},
};
export default meta;
type Story = StoryObj<typeof WorkflowExecutionResult>;
export const Idle: Story = {
args: {
status: {
isSuccess: false,
isError: false,
},
},
};
export const Success: Story = {
args: {
status: {
isSuccess: true,
isError: false,
successMessage: '200 OK - 156ms',
},
},
};
export const SuccessWithAdditionalInfo: Story = {
args: {
status: {
isSuccess: true,
isError: false,
successMessage: '200 OK - 156ms',
additionalInfo: '5 headers received',
},
},
};
export const Error: Story = {
args: {
result: JSON.stringify(
{ error: 'Internal Server Error', code: 500 },
null,
2,
),
status: {
isSuccess: false,
isError: true,
errorMessage: '500 Internal Server Error - 89ms',
},
},
};
export const ErrorWithAdditionalInfo: Story = {
args: {
result: 'Connection timeout',
language: 'plaintext',
status: {
isSuccess: false,
isError: true,
errorMessage: 'Request Failed',
additionalInfo: 'Connection timeout after 30 seconds',
},
},
};
export const Loading: Story = {
args: {
isTesting: true,
loadingMessage: 'Executing function...',
},
};
export const CustomIdleMessage: Story = {
args: {
idleMessage: 'Response will appear here',
status: {
isSuccess: false,
isError: false,
},
},
};
export const PlaintextContent: Story = {
args: {
result:
'This is plain text content\nwith multiple lines\nand no JSON formatting',
language: 'plaintext',
status: {
isSuccess: true,
isError: false,
successMessage: 'Text processed successfully',
},
},
};
export const LargeResult: Story = {
args: {
result: JSON.stringify(
{
users: Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
active: i % 2 === 0,
metadata: {
createdAt: new Date().toISOString(),
lastLogin: new Date().toISOString(),
permissions: ['read', 'write'],
},
})),
totalCount: 20,
page: 1,
limit: 20,
},
null,
2,
),
language: 'json',
height: '400px',
status: {
isSuccess: true,
isError: false,
successMessage: '200 OK - 234ms',
additionalInfo: '20 users retrieved',
},
},
};
export const EmptyResult: Story = {
args: {
result: '',
status: {
isSuccess: true,
isError: false,
successMessage: '204 No Content - 45ms',
},
},
};
export const HttpRequestResponse: Story = {
args: {
result: JSON.stringify(
{
id: '12345',
name: 'Acme Corp',
industry: 'Technology',
employees: 150,
founded: 2010,
headquarters: {
city: 'San Francisco',
state: 'CA',
country: 'USA',
},
revenue: '$50M',
isPublic: false,
},
null,
2,
),
language: 'json',
status: {
isSuccess: true,
isError: false,
successMessage: '200 OK - 123ms',
additionalInfo: '8 headers received',
},
idleMessage: 'Response',
loadingMessage: 'Sending request...',
},
};

View file

@ -1,6 +1,7 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@ -10,10 +11,9 @@ import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionCreateRecord } from '../WorkflowEditActionCreateRecord';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof WorkflowEditActionCreateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionCreateRecord',
title: 'Modules/Workflow/Actions/CreateRecord/EditAction',
component: WorkflowEditActionCreateRecord,
parameters: {
msw: graphqlMocks,

View file

@ -36,7 +36,7 @@ const DEFAULT_ACTION = {
} satisfies WorkflowDeleteRecordAction;
const meta: Meta<typeof WorkflowEditActionDeleteRecord> = {
title: 'Modules/Workflow/WorkflowEditActionDeleteRecord',
title: 'Modules/Workflow/Actions/DeleteRecord/EditAction',
component: WorkflowEditActionDeleteRecord,
parameters: {
msw: graphqlMocks,

View file

@ -35,7 +35,7 @@ const DEFAULT_ACTION = {
} satisfies WorkflowFindRecordsAction;
const meta: Meta<typeof WorkflowEditActionFindRecords> = {
title: 'Modules/Workflow/WorkflowEditActionFindRecords',
title: 'Modules/Workflow/Actions/FindRecords/EditAction',
component: WorkflowEditActionFindRecords,
parameters: {
msw: graphqlMocks,

View file

@ -66,7 +66,7 @@ const CONFIGURED_ACTION: WorkflowSendEmailAction = {
};
const meta: Meta<typeof WorkflowEditActionSendEmail> = {
title: 'Modules/Workflow/WorkflowEditActionSendEmail',
title: 'Modules/Workflow/Actions/SendEmail/EditAction',
component: WorkflowEditActionSendEmail,
parameters: {
msw: {

View file

@ -49,7 +49,7 @@ const DEFAULT_ACTION = {
} satisfies WorkflowUpdateRecordAction;
const meta: Meta<typeof WorkflowEditActionUpdateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionUpdateRecord',
title: 'Modules/Workflow/Actions/UpdateRecord/EditAction',
component: WorkflowEditActionUpdateRecord,
parameters: {
msw: graphqlMocks,

View file

@ -0,0 +1,64 @@
import {
ExecutionStatus,
WorkflowExecutionResult,
} from '@/workflow/components/WorkflowExecutionResult';
import type { HttpRequestTestData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData';
export const HttpRequestExecutionResult = ({
httpRequestTestData,
isTesting = false,
}: {
httpRequestTestData: HttpRequestTestData;
isTesting?: boolean;
}) => {
const result =
httpRequestTestData.output.data || httpRequestTestData.output.error || '';
const isSuccess =
httpRequestTestData.output.status !== undefined &&
httpRequestTestData.output.status >= 200 &&
httpRequestTestData.output.status < 400;
const isError =
httpRequestTestData.output.error !== undefined ||
(httpRequestTestData.output.status !== undefined &&
httpRequestTestData.output.status >= 400);
const status: ExecutionStatus = {
isSuccess,
isError,
successMessage: httpRequestTestData.output.status
? `${httpRequestTestData.output.status} ${httpRequestTestData.output.statusText}${
httpRequestTestData.output.duration
? ` - ${httpRequestTestData.output.duration}ms`
: ''
}`
: undefined,
errorMessage: httpRequestTestData.output.status
? `${httpRequestTestData.output.status} ${httpRequestTestData.output.statusText}${
httpRequestTestData.output.duration
? ` - ${httpRequestTestData.output.duration}ms`
: ''
}`
: 'Request Failed',
additionalInfo:
isSuccess &&
Object.keys(httpRequestTestData.output.headers || {}).length > 0
? `${Object.keys(httpRequestTestData.output.headers || {}).length} headers received`
: isError && httpRequestTestData.output.error
? httpRequestTestData.output.error
: undefined,
};
return (
<WorkflowExecutionResult
result={result}
language={httpRequestTestData.language}
height="100%"
status={status}
isTesting={isTesting}
loadingMessage="Sending request..."
idleMessage="Response"
/>
);
};

View file

@ -0,0 +1,82 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { WorkflowStep } from '@/workflow/types/Workflow';
import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep';
import { HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { httpRequestTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
const StyledVariableInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
type HttpRequestTestVariableInputProps = {
httpRequestFormData: HttpRequestFormData;
actionId: string;
readonly?: boolean;
};
export const HttpRequestTestVariableInput = ({
httpRequestFormData,
actionId,
readonly,
}: HttpRequestTestVariableInputProps) => {
const [httpRequestTestData, setHttpRequestTestData] = useRecoilState(
httpRequestTestDataFamilyState(actionId),
);
const mockStep: WorkflowStep = {
id: 'test-step',
name: 'Test Step',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: httpRequestFormData,
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
const variablesUsed = getWorkflowVariablesUsedInStep({ step: mockStep });
const variableArray = Array.from(variablesUsed);
const handleVariableChange = (variablePath: string, value: string) => {
setHttpRequestTestData((prev) => ({
...prev,
variableValues: {
...prev.variableValues,
[variablePath]: value,
},
}));
};
if (variableArray.length === 0) {
return null;
}
return (
<FormFieldInputContainer>
<StyledVariableInputsContainer>
{variableArray.map((variablePath) => (
<FormTextFieldInput
key={variablePath}
label={`${variablePath}`}
placeholder="Enter test value"
readonly={readonly}
defaultValue={
httpRequestTestData.variableValues[variablePath] || ''
}
onChange={(value) =>
handleVariableChange(variablePath, value || '')
}
/>
))}
</StyledVariableInputsContainer>
</FormFieldInputContainer>
);
};

View file

@ -6,19 +6,30 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { useIcons } from 'twenty-ui/display';
import { IconPlayerPlay, IconSettings, useIcons } from 'twenty-ui/display';
import {
HTTP_METHODS,
JSON_RESPONSE_PLACEHOLDER,
} from '../constants/HttpRequest';
import { WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID } from '../constants/WorkflowHttpRequestTabListComponentId';
import { useHttpRequestForm } from '../hooks/useHttpRequestForm';
import { useHttpRequestOutputSchema } from '../hooks/useHttpRequestOutputSchema';
import { useTestHttpRequest } from '../hooks/useTestHttpRequest';
import { WorkflowHttpRequestTabId } from '../types/WorkflowHttpRequestTabId';
import { BodyInput } from './BodyInput';
import { HttpRequestExecutionResult } from './HttpRequestExecutionResult';
import { HttpRequestTestVariableInput } from './HttpRequestTestVariableInput';
import { KeyValuePairInput } from './KeyValuePairInput';
type WorkflowEditActionHttpRequestProps = {
@ -29,12 +40,60 @@ type WorkflowEditActionHttpRequestProps = {
};
};
const StyledTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledTestTabContent = styled.div`
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(4)};
height: 100%;
min-height: 400px;
`;
const StyledConfigurationTabContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
height: 100%;
flex: 1;
`;
const StyledFullHeightFormRawJsonFieldInput = styled(FormRawJsonFieldInput)`
flex: 1;
display: flex;
flex-direction: column;
& > div:last-child {
flex: 1;
display: flex;
flex-direction: column;
& > div {
flex: 1;
max-height: none !important;
height: 100%;
& > div {
height: 100%;
}
}
}
`;
export const WorkflowEditActionHttpRequest = ({
action,
actionOptions,
}: WorkflowEditActionHttpRequestProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID,
);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
@ -54,10 +113,34 @@ export const WorkflowEditActionHttpRequest = ({
readonly: actionOptions.readonly === true,
});
const { testHttpRequest, isTesting, httpRequestTestData } =
useTestHttpRequest(action.id);
const handleTestRequest = async () => {
if (actionOptions.readonly === true) {
return;
}
await testHttpRequest(formData, httpRequestTestData.variableValues);
};
const tabs = [
{
id: WorkflowHttpRequestTabId.CONFIGURATION,
title: 'Configuration',
Icon: IconSettings,
},
{ id: WorkflowHttpRequestTabId.TEST, title: 'Test', Icon: IconPlayerPlay },
];
useEffect(() => () => saveAction.flush(), [saveAction]);
return (
<>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID}
/>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
@ -72,51 +155,79 @@ export const WorkflowEditActionHttpRequest = ({
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}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
{activeTabId === WorkflowHttpRequestTabId.CONFIGURATION && (
<StyledConfigurationTabContent>
<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}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
<KeyValuePairInput
label="Headers Input"
defaultValue={formData.headers}
onChange={(value) => handleFieldChange('headers', value)}
readonly={actionOptions.readonly}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
/>
<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}
/>
{isMethodWithBody(formData.method) && (
<BodyInput
defaultValue={formData.body}
onChange={(value) => handleFieldChange('body', value)}
readonly={actionOptions.readonly}
/>
)}
<StyledFullHeightFormRawJsonFieldInput
label="Expected Response Body"
placeholder={JSON_RESPONSE_PLACEHOLDER}
defaultValue={outputSchema}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
error={error}
/>
</StyledConfigurationTabContent>
)}
{activeTabId === WorkflowHttpRequestTabId.TEST && (
<StyledTestTabContent>
<HttpRequestTestVariableInput
httpRequestFormData={formData}
actionId={action.id}
readonly={actionOptions.readonly}
/>
<HttpRequestExecutionResult
httpRequestTestData={httpRequestTestData}
isTesting={isTesting}
/>
</StyledTestTabContent>
)}
<FormRawJsonFieldInput
label="Expected Response Body"
placeholder={JSON_RESPONSE_PLACEHOLDER}
defaultValue={outputSchema}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
error={error}
/>
</WorkflowStepBody>
{activeTabId === WorkflowHttpRequestTabId.TEST && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton
title="Test"
onClick={handleTestRequest}
disabled={isTesting || actionOptions.readonly}
/>,
]}
/>
)}
</>
);
};

View file

@ -0,0 +1,370 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { HttpRequestExecutionResult } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestExecutionResult';
import type { HttpRequestTestData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData';
const meta: Meta<typeof HttpRequestExecutionResult> = {
title: 'Modules/Workflow/Actions/HttpRequest/ExecutionResult',
component: HttpRequestExecutionResult,
decorators: [ComponentDecorator, SnackBarDecorator, I18nFrontDecorator],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof HttpRequestExecutionResult>;
const successTestData: HttpRequestTestData = {
variableValues: {},
output: {
data: JSON.stringify(
{
id: '12345',
name: 'Acme Corp',
industry: 'Technology',
employees: 150,
revenue: '$50M',
isActive: true,
},
null,
2,
),
status: 200,
statusText: 'OK',
headers: {
'content-type': 'application/json',
'x-rate-limit-remaining': '99',
'cache-control': 'no-cache',
},
duration: 156,
},
language: 'json',
height: 300,
};
export const Success: Story = {
args: {
httpRequestTestData: successTestData,
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('200 OK - 156ms')).toBeVisible();
expect(await canvas.findByText('3 headers received')).toBeVisible();
},
};
export const SuccessNoHeaders: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
...successTestData.output,
headers: {},
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('200 OK - 156ms')).toBeVisible();
},
};
export const Created: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: JSON.stringify(
{ id: '67890', message: 'Resource created successfully' },
null,
2,
),
status: 201,
statusText: 'Created',
headers: {
'content-type': 'application/json',
location: '/api/resources/67890',
},
duration: 234,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('201 Created - 234ms')).toBeVisible();
expect(await canvas.findByText('2 headers received')).toBeVisible();
},
};
export const NoContent: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: '',
status: 204,
statusText: 'No Content',
headers: {},
duration: 45,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('204 No Content - 45ms')).toBeVisible();
},
};
export const BadRequest: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: JSON.stringify(
{
error: 'Bad Request',
message: 'Invalid request parameters',
details: [
'Field "name" is required',
'Field "email" must be valid',
],
},
null,
2,
),
status: 400,
statusText: 'Bad Request',
headers: {
'content-type': 'application/json',
},
duration: 67,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('400 Bad Request - 67ms')).toBeVisible();
},
};
export const NotFound: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: JSON.stringify(
{
error: 'Not Found',
message: 'The requested resource was not found',
},
null,
2,
),
status: 404,
statusText: 'Not Found',
headers: {
'content-type': 'application/json',
},
duration: 89,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('404 Not Found - 89ms')).toBeVisible();
},
};
export const InternalServerError: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: JSON.stringify(
{
error: 'Internal Server Error',
message: 'An unexpected error occurred',
timestamp: '2023-12-07T10:30:00Z',
},
null,
2,
),
status: 500,
statusText: 'Internal Server Error',
headers: {
'content-type': 'application/json',
},
duration: 2345,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText('500 Internal Server Error - 2345ms'),
).toBeVisible();
},
};
export const NetworkError: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
error: 'Network connection failed: timeout after 30 seconds',
headers: {},
duration: 30000,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Request Failed')).toBeVisible();
expect(
await canvas.findByText(
'Network connection failed: timeout after 30 seconds',
),
).toBeVisible();
},
};
export const Loading: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: 'Configure your request above, then press "Test"',
headers: {},
},
},
isTesting: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Sending request...')).toBeVisible();
},
};
export const PlaintextResponse: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: 'Hello World!\nThis is a plain text response from the server.\nIt contains multiple lines of text.',
status: 200,
statusText: 'OK',
headers: {
'content-type': 'text/plain',
},
duration: 78,
},
language: 'plaintext',
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('200 OK - 78ms')).toBeVisible();
},
};
export const LargeResponse: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: JSON.stringify(
{
users: Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
active: i % 2 === 0,
profile: {
firstName: `First${i + 1}`,
lastName: `Last${i + 1}`,
company: `Company ${Math.floor(i / 10) + 1}`,
position: ['Developer', 'Designer', 'Manager', 'Analyst'][
i % 4
],
},
metadata: {
createdAt: new Date(2023, 0, 1 + i).toISOString(),
lastLogin: new Date(2023, 11, 1 + (i % 30)).toISOString(),
permissions: ['read', 'write', 'admin'].slice(0, (i % 3) + 1),
},
})),
pagination: {
totalCount: 500,
page: 1,
limit: 50,
totalPages: 10,
},
},
null,
2,
),
status: 200,
statusText: 'OK',
headers: {
'content-type': 'application/json',
'x-total-count': '500',
'x-rate-limit-remaining': '98',
'cache-control': 'public, max-age=300',
},
duration: 567,
},
height: 500,
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('200 OK - 567ms')).toBeVisible();
expect(await canvas.findByText('4 headers received')).toBeVisible();
},
};
export const Unauthorized: Story = {
args: {
httpRequestTestData: {
...successTestData,
output: {
data: JSON.stringify(
{
error: 'Unauthorized',
message: 'Authentication credentials are missing or invalid',
},
null,
2,
),
status: 401,
statusText: 'Unauthorized',
headers: {
'content-type': 'application/json',
'www-authenticate': 'Bearer',
},
duration: 123,
},
},
isTesting: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('401 Unauthorized - 123ms')).toBeVisible();
},
};

View file

@ -0,0 +1,227 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { HttpRequestTestVariableInput } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestTestVariableInput';
import { HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
const meta: Meta<typeof HttpRequestTestVariableInput> = {
title: 'Modules/Workflow/Actions/HttpRequest/TestVariableInput',
component: HttpRequestTestVariableInput,
decorators: [ComponentDecorator],
parameters: {
layout: 'padded',
},
};
export default meta;
type Story = StoryObj<typeof HttpRequestTestVariableInput>;
const formDataWithVariables: HttpRequestFormData = {
url: 'https://api.example.com/users/{{user.id}}',
method: 'POST',
headers: {
Authorization: 'Bearer {{auth.token}}',
'Content-Type': 'application/json',
},
body: {
name: '{{user.name}}',
email: '{{contact.email}}',
},
};
const formDataWithManyVariables: HttpRequestFormData = {
url: 'https://{{api.host}}/{{api.version}}/{{endpoint.path}}',
method: 'PUT',
headers: {
Authorization: 'Bearer {{auth.token}}',
'X-User-ID': '{{user.id}}',
'X-Request-ID': '{{request.id}}',
'Content-Type': 'application/json',
},
body: {
userData: {
name: '{{user.name}}',
email: '{{user.email}}',
department: '{{user.department}}',
},
metadata: {
timestamp: '{{current.timestamp}}',
source: '{{app.name}}',
},
},
};
const formDataWithNoVariables: HttpRequestFormData = {
url: 'https://api.example.com/status',
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
body: {},
};
export const WithVariables: Story = {
args: {
httpRequestFormData: formDataWithVariables,
actionId: 'test-action-1',
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('user.id')).toBeVisible();
expect(await canvas.findByText('auth.token')).toBeVisible();
expect(await canvas.findByText('user.name')).toBeVisible();
expect(await canvas.findByText('contact.email')).toBeVisible();
// Should have 4 input fields
const inputs = canvas.getAllByRole('textbox');
expect(inputs).toHaveLength(4);
},
};
export const WithManyVariables: Story = {
args: {
httpRequestFormData: formDataWithManyVariables,
actionId: 'test-action-2',
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Should have 11 input fields
const inputs = canvas.getAllByRole('textbox');
expect(inputs).toHaveLength(11);
// Check some of the variable labels
expect(await canvas.findByText('api.host')).toBeVisible();
expect(await canvas.findByText('user.name')).toBeVisible();
expect(await canvas.findByText('current.timestamp')).toBeVisible();
},
};
export const NoVariables: Story = {
args: {
httpRequestFormData: formDataWithNoVariables,
actionId: 'test-action-3',
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Should not render anything when there are no variables
// With no variables, there should be no input fields
const inputs = canvas.queryAllByRole('textbox');
expect(inputs).toHaveLength(0);
},
};
export const ReadonlyMode: Story = {
args: {
httpRequestFormData: formDataWithVariables,
actionId: 'test-action-4',
readonly: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// In readonly mode, variables should still be displayed
expect(await canvas.findByText('user.id')).toBeVisible();
expect(await canvas.findByText('auth.token')).toBeVisible();
},
};
export const WithPrefilledValues: Story = {
args: {
httpRequestFormData: formDataWithVariables,
actionId: 'test-action-5',
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputs = canvas.getAllByRole('textbox');
const userIdInput = inputs[0];
await userEvent.type(userIdInput, '12345');
const tokenInput = inputs[1];
await userEvent.type(tokenInput, 'abc123xyz');
const nameInput = inputs[2];
await userEvent.type(nameInput, 'John Doe');
},
};
export const SingleVariable: Story = {
args: {
httpRequestFormData: {
url: 'https://api.example.com/users/{{user.id}}',
method: 'GET',
headers: {},
body: {},
},
actionId: 'test-action-6',
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('user.id')).toBeVisible();
// Should have only 1 input field
const inputs = canvas.getAllByRole('textbox');
expect(inputs).toHaveLength(1);
},
};
export const ComplexNestedVariables: Story = {
args: {
httpRequestFormData: {
url: 'https://api.example.com/{{resource.type}}/{{resource.id}}',
method: 'PATCH',
headers: {
Authorization: 'Bearer {{auth.jwt}}',
'X-Trace-ID': '{{trace.id}}',
},
body: {
operation: 'update',
data: {
profile: {
firstName: '{{user.profile.firstName}}',
lastName: '{{user.profile.lastName}}',
settings: {
theme: '{{user.preferences.theme}}',
notifications: '{{user.preferences.notifications}}',
},
},
audit: {
updatedBy: '{{current.user.id}}',
updatedAt: '{{current.timestamp}}',
},
},
},
},
actionId: 'test-action-7',
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Should have 10 input fields for all the nested variables
const inputs = canvas.getAllByRole('textbox');
expect(inputs).toHaveLength(10);
// Check some complex variable paths
expect(await canvas.findByText('user.profile.firstName')).toBeVisible();
expect(await canvas.findByText('user.preferences.theme')).toBeVisible();
expect(await canvas.findByText('current.timestamp')).toBeVisible();
},
};

View file

@ -1,7 +1,7 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, waitFor, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
@ -69,7 +69,7 @@ const CONFIGURED_ACTION: WorkflowHttpRequestAction = {
};
const meta: Meta<typeof WorkflowEditActionHttpRequest> = {
title: 'Modules/Workflow/WorkflowEditActionHttpRequest',
title: 'Modules/Workflow/Actions/HttpRequest/EditAction',
component: WorkflowEditActionHttpRequest,
parameters: {
msw: graphqlMocks,
@ -80,7 +80,7 @@ const meta: Meta<typeof WorkflowEditActionHttpRequest> = {
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ComponentWithRouterDecorator,
SnackBarDecorator,
WorkspaceDecorator,
I18nFrontDecorator,

View file

@ -12,10 +12,7 @@ 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 HttpRequestBody = Record<string, any>;
export type HttpRequestFormData = {
url: string;
@ -28,3 +25,12 @@ 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}';
export const DEFAULT_HTTP_REQUEST_OUTPUT_VALUE = {
data: 'Configure your request above, then press "Test"',
status: undefined,
statusText: undefined,
headers: {},
duration: undefined,
error: undefined,
};

View file

@ -0,0 +1,2 @@
export const WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID =
'workflow-http-request-tab-list';

View file

@ -0,0 +1,299 @@
import { HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { act, renderHook } from '@testing-library/react';
import React from 'react';
import { RecoilRoot } from 'recoil';
import { resolveInput } from 'twenty-shared/utils';
import { useTestHttpRequest } from '../useTestHttpRequest';
// Mock the resolveInput function
jest.mock('twenty-shared/utils', () => ({
resolveInput: jest.fn((input, context) => {
// For testing purposes, we'll actually do the replacement for simple cases
if (typeof input === 'string') {
return input.replace(/{{([^}]+)}}/g, (match, path) => {
const parts = path.split('.');
let current = context;
for (const part of parts) {
if (
current !== null &&
current !== undefined &&
typeof current === 'object' &&
part in current
) {
current = (current as any)[part];
} else {
return 'undefined';
}
}
return current;
});
} else if (typeof input === 'object' && input !== null) {
// Handle object replacement recursively
const result = { ...input };
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string') {
result[key] = value.replace(/{{([^}]+)}}/g, (match, path) => {
const parts = path.split('.');
let current = context;
for (const part of parts) {
if (
current !== null &&
current !== undefined &&
typeof current === 'object' &&
part in current
) {
current = (current as any)[part];
} else {
return 'undefined';
}
}
return current;
});
}
}
return result;
}
return input;
}),
}));
// Mock fetch
global.fetch = jest.fn();
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
describe('useTestHttpRequest', () => {
const actionId = 'test-action-id';
const mockFormData: HttpRequestFormData = {
url: 'https://api.example.com/users',
method: 'GET',
headers: { Authorization: 'Bearer {{token}}' },
body: undefined,
};
const mockVariableValues = {
token: 'test-token-123',
};
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(RecoilRoot, null, children);
beforeEach(() => {
jest.clearAllMocks();
});
it('should initialize with correct default values', () => {
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
expect(result.current.isTesting).toBe(false);
expect(result.current.testHttpRequest).toBeInstanceOf(Function);
expect(result.current.httpRequestTestData).toBeDefined();
});
it('should handle successful GET request', async () => {
const mockResponse = {
status: 200,
statusText: 'OK',
json: jest.fn().mockResolvedValue({ id: 1, name: 'John' }),
headers: new Map([['content-type', 'application/json']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: 'Bearer test-token-123',
}),
}),
);
expect(result.current.isTesting).toBe(false);
expect(result.current.httpRequestTestData.output?.status).toBe(200);
expect(result.current.httpRequestTestData.output?.data).toBe(
'{\n "id": 1,\n "name": "John"\n}',
);
expect(result.current.httpRequestTestData.output?.error).toBeUndefined();
});
it('should handle POST request with body', async () => {
const postFormData: HttpRequestFormData = {
...mockFormData,
method: 'POST',
body: { name: 'Jane', email: 'jane@example.com' },
};
const mockResponse = {
status: 201,
statusText: 'Created',
json: jest.fn().mockResolvedValue({ id: 2, name: 'Jane' }),
headers: new Map([['content-type', 'application/json']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(postFormData, mockVariableValues);
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'Jane', email: 'jane@example.com' }),
}),
);
expect(result.current.httpRequestTestData.output?.status).toBe(201);
});
it('should handle non-JSON responses', async () => {
const mockResponse = {
status: 200,
statusText: 'OK',
text: jest.fn().mockResolvedValue('Plain text response'),
headers: new Map([['content-type', 'text/plain']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(result.current.httpRequestTestData.output?.data).toBe(
'Plain text response',
);
expect(result.current.httpRequestTestData.language).toBe('plaintext');
});
it('should handle request errors', async () => {
const errorMessage = 'Network error';
mockFetch.mockRejectedValueOnce(new Error(errorMessage));
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(result.current.isTesting).toBe(false);
expect(result.current.httpRequestTestData.output?.error).toBe(errorMessage);
expect(result.current.httpRequestTestData.output?.status).toBeUndefined();
expect(result.current.httpRequestTestData.language).toBe('plaintext');
});
it('should set isTesting to true during request', async () => {
// Create a promise that we can control
let resolvePromise: (value: any) => void;
const mockPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockFetch.mockReturnValueOnce(mockPromise as any);
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
// Start the request
act(() => {
result.current.testHttpRequest(mockFormData, mockVariableValues);
});
// Should be testing now
expect(result.current.isTesting).toBe(true);
// Complete the request
await act(async () => {
resolvePromise!({
status: 200,
statusText: 'OK',
json: jest.fn().mockResolvedValue({}),
headers: new Map([['content-type', 'application/json']]),
});
await mockPromise;
});
// Should no longer be testing
expect(result.current.isTesting).toBe(false);
});
it('should convert flat variable paths to nested context for proper substitution', async () => {
const formDataWithNestedVariables: HttpRequestFormData = {
url: 'https://api.example.com/users',
method: 'POST',
headers: { Authorization: 'Bearer {{auth.token}}' },
body: { name: '{{trigger.properties.after.name}}' },
};
const flatVariableValues = {
'auth.token': 'test-token-123',
'trigger.properties.after.name': 'Yo',
};
const mockResponse = {
status: 201,
statusText: 'Created',
json: jest.fn().mockResolvedValue({ success: true }),
headers: new Map([['content-type', 'application/json']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(
formDataWithNestedVariables,
flatVariableValues,
);
});
// The mocked resolveInput should have been called with nested context
expect(resolveInput).toHaveBeenCalledWith('https://api.example.com/users', {
auth: { token: 'test-token-123' },
trigger: { properties: { after: { name: 'Yo' } } },
});
expect(resolveInput).toHaveBeenCalledWith(
{ Authorization: 'Bearer {{auth.token}}' },
{
auth: { token: 'test-token-123' },
trigger: { properties: { after: { name: 'Yo' } } },
},
);
expect(resolveInput).toHaveBeenCalledWith(
{ name: '{{trigger.properties.after.name}}' },
{
auth: { token: 'test-token-123' },
trigger: { properties: { after: { name: 'Yo' } } },
},
);
});
});

View file

@ -0,0 +1,137 @@
import { HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { httpRequestTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { resolveInput } from 'twenty-shared/utils';
const convertFlatVariablesToNestedContext = (flatVariables: {
[variablePath: string]: any;
}): Record<string, unknown> => {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(flatVariables)) {
const parts = key.split('.');
let current = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
return result;
};
export const useTestHttpRequest = (actionId: string) => {
const [isTesting, setIsTesting] = useState(false);
const [httpRequestTestData, setHttpRequestTestData] = useRecoilState(
httpRequestTestDataFamilyState(actionId),
);
const testHttpRequest = async (
httpRequestFormData: HttpRequestFormData,
variableValues: { [variablePath: string]: any },
) => {
setIsTesting(true);
const startTime = Date.now();
try {
const nestedVariableContext =
convertFlatVariablesToNestedContext(variableValues);
const substitutedUrl = resolveInput(
httpRequestFormData.url,
nestedVariableContext,
);
const substitutedHeaders = resolveInput(
httpRequestFormData.headers,
nestedVariableContext,
);
const substitutedBody = resolveInput(
httpRequestFormData.body,
nestedVariableContext,
);
const requestOptions: RequestInit = {
method: httpRequestFormData.method,
headers: {
'Content-Type': 'application/json',
...(substitutedHeaders as Record<string, string>),
},
};
if (['POST', 'PUT', 'PATCH'].includes(httpRequestFormData.method)) {
if (substitutedBody !== undefined) {
if (typeof substitutedBody === 'string') {
requestOptions.body = substitutedBody;
} else {
requestOptions.body = JSON.stringify(substitutedBody);
}
}
}
const response = await fetch(substitutedUrl as string, requestOptions);
const duration = Date.now() - startTime;
let responseData: string;
const contentType = response.headers.get('content-type');
if (contentType !== null && contentType.includes('application/json')) {
const jsonData = await response.json();
responseData = JSON.stringify(jsonData, null, 2);
} else {
responseData = await response.text();
}
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
setHttpRequestTestData((prev) => ({
...prev,
output: {
data: responseData,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
duration,
error: undefined,
},
language:
contentType !== null && contentType.includes('application/json')
? 'json'
: 'plaintext',
}));
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : 'HTTP request failed';
setHttpRequestTestData((prev) => ({
...prev,
output: {
data: undefined,
status: undefined,
statusText: undefined,
headers: {},
duration,
error: errorMessage,
},
language: 'plaintext',
}));
} finally {
setIsTesting(false);
}
};
return {
testHttpRequest,
isTesting,
httpRequestTestData,
};
};

View file

@ -0,0 +1,16 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { DEFAULT_HTTP_REQUEST_OUTPUT_VALUE } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { HttpRequestTestData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData';
export const httpRequestTestDataFamilyState = createFamilyState<
HttpRequestTestData,
string
>({
key: 'httpRequestTestDataFamilyState',
defaultValue: {
language: 'plaintext',
height: 400,
variableValues: {},
output: DEFAULT_HTTP_REQUEST_OUTPUT_VALUE,
},
});

View file

@ -0,0 +1,13 @@
export type HttpRequestTestData = {
variableValues: { [variablePath: string]: any };
output: {
data?: string;
status?: number;
statusText?: string;
headers?: Record<string, string>;
duration?: number;
error?: string;
};
language: 'plaintext' | 'json';
height: number;
};

View file

@ -0,0 +1,6 @@
export type WorkflowHttpRequestTabIdType = 'configuration' | 'test';
export enum WorkflowHttpRequestTabId {
CONFIGURATION = 'configuration',
TEST = 'test',
}

View file

@ -121,13 +121,11 @@ export function formatResult<T>(
// This is a temporary fix to handle a bug in the frontend where the date gets returned in the wrong timezone,
// thus returning the wrong date.
//
// In short, for example :
// - DB stores `2025-01-01`
// - TypeORM .returning() returns `2024-12-31T23:00:00.000Z`
// - we shift +1h (or whatever the timezone offset is on the server)
// - we return `2025-01-01T00:00:00.000Z`
//
// See this PR for more details: https://github.com/twentyhq/twenty/pull/9700
const serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift =
new Date().getTimezoneOffset() * 60 * 1000;

View file

@ -1,4 +1,3 @@
//
import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';

View file

@ -1,4 +1,3 @@
//
import { Injectable, Logger } from '@nestjs/common';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
@ -18,7 +19,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard';

View file

@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@ -10,7 +12,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowCodeAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard';
import { WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type';

View file

@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
@ -8,7 +10,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowFilterAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';

View file

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import { Repository } from 'typeorm';
import { resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
@ -19,7 +20,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
RecordCRUDActionExceptionCode,

View file

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { isValidUuid } from 'twenty-shared/utils';
import { isValidUuid, resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
@ -13,7 +13,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
RecordCRUDActionExceptionCode,

View file

@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { Entity } from '@microsoft/microsoft-graph-types';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { ObjectLiteral } from 'typeorm';
import { resolveInput } from 'twenty-shared/utils';
import {
ObjectRecordFilter,
@ -24,7 +25,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
RecordCRUDActionExceptionCode,

View file

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import deepEqual from 'deep-equal';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { isDefined, isValidUuid, resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
@ -15,7 +15,6 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
RecordCRUDActionExceptionCode,

View file

@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
@ -7,7 +9,6 @@ import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-
import { ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()

View file

@ -16,12 +16,14 @@
"@preconstruct/cli": "^2.8.12",
"@prettier/sync": "^0.5.2",
"@types/babel__preset-env": "^7",
"@types/handlebars": "^4.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"glob": "^11.0.1",
"tsx": "^4.19.3"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"handlebars": "^4.7.8",
"libphonenumber-js": "^1.10.26",
"zod": "3.23.8"
},

View file

@ -46,3 +46,4 @@ export { isValidVariable } from './validation/isValidVariable';
export { normalizeLocale } from './validation/normalizeLocale';
export { getCountryCodesForCallingCode } from './validation/phones-value/getCountryCodesForCallingCode';
export { isValidCountryCode } from './validation/phones-value/isValidCountryCode';
export { resolveInput } from './variable-resolver';

View file

@ -1,4 +1,4 @@
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { resolveInput } from './variable-resolver';
describe('resolveInput', () => {
const context = {

View file

@ -1,7 +1,13 @@
import { isNil, isString } from '@nestjs/common/utils/shared.utils';
import Handlebars from 'handlebars';
const isNil = (value: any): value is null | undefined => {
return value === null || value === undefined;
};
const isString = (value: any): value is string => {
return typeof value === 'string';
};
const VARIABLE_PATTERN = RegExp('\\{\\{(.*?)\\}\\}', 'g');
export const resolveInput = (

View file

@ -22605,6 +22605,15 @@ __metadata:
languageName: node
linkType: hard
"@types/handlebars@npm:^4.1.0":
version: 4.1.0
resolution: "@types/handlebars@npm:4.1.0"
dependencies:
handlebars: "npm:*"
checksum: 10c0/50e85efb4a9306c8b2278dd1570714e9021f201ef317a259ce2d63406057e6575f3066b862bed3d192828d1cc32b80d3a0d2e18d0cd9cda98ec5f1f69038e342
languageName: node
linkType: hard
"@types/har-format@npm:*":
version: 1.2.15
resolution: "@types/har-format@npm:1.2.15"
@ -36890,7 +36899,7 @@ __metadata:
languageName: node
linkType: hard
"handlebars@npm:^4.7.7, handlebars@npm:^4.7.8":
"handlebars@npm:*, handlebars@npm:^4.7.7, handlebars@npm:^4.7.8":
version: 4.7.8
resolution: "handlebars@npm:4.7.8"
dependencies:
@ -54866,8 +54875,10 @@ __metadata:
"@prettier/sync": "npm:^0.5.2"
"@sniptt/guards": "npm:^0.2.0"
"@types/babel__preset-env": "npm:^7"
"@types/handlebars": "npm:^4.1.0"
babel-plugin-module-resolver: "npm:^5.0.2"
glob: "npm:^11.0.1"
handlebars: "npm:^4.7.8"
libphonenumber-js: "npm:^1.10.26"
tsx: "npm:^4.19.3"
zod: "npm:3.23.8"