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:
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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: {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+64
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+82
@@ -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>
|
||||
);
|
||||
};
|
||||
+153
-42
@@ -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}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+370
@@ -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();
|
||||
},
|
||||
};
|
||||
+227
@@ -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();
|
||||
},
|
||||
};
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+10
-4
@@ -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,
|
||||
};
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID =
|
||||
'workflow-http-request-tab-list';
|
||||
+299
@@ -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' } } },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+137
@@ -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,
|
||||
};
|
||||
};
|
||||
+16
@@ -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,
|
||||
},
|
||||
});
|
||||
+13
@@ -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;
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export type WorkflowHttpRequestTabIdType = 'configuration' | 'test';
|
||||
|
||||
export enum WorkflowHttpRequestTabId {
|
||||
CONFIGURATION = 'configuration',
|
||||
TEST = 'test',
|
||||
}
|
||||
Reference in New Issue
Block a user