mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
de802b1447
commit
b7022202fa
39 changed files with 1799 additions and 169 deletions
|
|
@ -45,7 +45,7 @@ const RelationFieldValueSetterEffect = () => {
|
|||
};
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'RecordIndex/Table/RecordTableCell',
|
||||
title: 'Modules/ObjectRecord/RecordTable/RecordTableCell',
|
||||
decorators: [
|
||||
MemoryRouterDecorator,
|
||||
ChipGeneratorsDecorator,
|
||||
|
|
|
|||
|
|
@ -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)});
|
||||
`}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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...',
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export const WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID =
|
||||
'workflow-http-request-tab-list';
|
||||
|
|
@ -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' } } },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export type WorkflowHttpRequestTabIdType = 'configuration' | 'test';
|
||||
|
||||
export enum WorkflowHttpRequestTabId {
|
||||
CONFIGURATION = 'configuration',
|
||||
TEST = 'test',
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
//
|
||||
import { Scope } from '@nestjs/common';
|
||||
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
//
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
@ -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 = (
|
||||
13
yarn.lock
13
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue