diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx index 58fa0639b4..8bfa742ee2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx @@ -45,7 +45,7 @@ const RelationFieldValueSetterEffect = () => { }; const meta: Meta = { - title: 'RecordIndex/Table/RecordTableCell', + title: 'Modules/ObjectRecord/RecordTable/RecordTableCell', decorators: [ MemoryRouterDecorator, ChipGeneratorsDecorator, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValueCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValueCell.tsx index 3190ecef13..bbb100415e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValueCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValueCell.tsx @@ -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)}); `} `; diff --git a/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx b/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx index f1aa8e0600..9add7c927c 100644 --- a/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx +++ b/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx @@ -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 = ( - - - 200 OK - {serverlessFunctionTestData.output.duration}ms - - ); + const isSuccess = + serverlessFunctionTestData.output.status === + ServerlessFunctionExecutionStatus.SUCCESS; - const ErrorLeftNode = ( - - - 500 Error - {serverlessFunctionTestData.output.duration}ms - - ); + const isError = + serverlessFunctionTestData.output.status === + ServerlessFunctionExecutionStatus.ERROR; - const IdleLeftNode = 'Output'; - - const PendingLeftNode = isTesting && ( - - - - - Running function - - ); - - 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 ( - - ]} - /> - - + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowExecutionResult.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowExecutionResult.tsx new file mode 100644 index 0000000000..9982785f25 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowExecutionResult.tsx @@ -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 = ( + + + + {status.successMessage} + {status.additionalInfo && ( + {status.additionalInfo} + )} + + + ); + + const ErrorLeftNode = ( + + + + {status.errorMessage} + {status.additionalInfo && ( + {status.additionalInfo} + )} + + + ); + + const IdleLeftNode = idleMessage; + + const PendingLeftNode = isTesting && ( + + + + + {loadingMessage} + + ); + + const computeLeftNode = () => { + if (isTesting) { + return PendingLeftNode; + } + if (status.isError) { + return ErrorLeftNode; + } + if (status.isSuccess) { + return SuccessLeftNode; + } + return IdleLeftNode; + }; + + return ( + + ]} + /> + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/__stories__/WorkflowExecutionResult.stories.tsx b/packages/twenty-front/src/modules/workflow/components/__stories__/WorkflowExecutionResult.stories.tsx new file mode 100644 index 0000000000..d5cfe1d72c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/__stories__/WorkflowExecutionResult.stories.tsx @@ -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 = { + 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; + +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...', + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionCreateRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionCreateRecord.stories.tsx index 6cb63b0e46..cf52c04c4d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionCreateRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionCreateRecord.stories.tsx @@ -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 = { - title: 'Modules/Workflow/WorkflowEditActionCreateRecord', + title: 'Modules/Workflow/Actions/CreateRecord/EditAction', component: WorkflowEditActionCreateRecord, parameters: { msw: graphqlMocks, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionDeleteRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionDeleteRecord.stories.tsx index c75a531986..2db64a4d61 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionDeleteRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionDeleteRecord.stories.tsx @@ -36,7 +36,7 @@ const DEFAULT_ACTION = { } satisfies WorkflowDeleteRecordAction; const meta: Meta = { - title: 'Modules/Workflow/WorkflowEditActionDeleteRecord', + title: 'Modules/Workflow/Actions/DeleteRecord/EditAction', component: WorkflowEditActionDeleteRecord, parameters: { msw: graphqlMocks, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFindRecords.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFindRecords.stories.tsx index 704ebbb944..8aeb7287ed 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFindRecords.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFindRecords.stories.tsx @@ -35,7 +35,7 @@ const DEFAULT_ACTION = { } satisfies WorkflowFindRecordsAction; const meta: Meta = { - title: 'Modules/Workflow/WorkflowEditActionFindRecords', + title: 'Modules/Workflow/Actions/FindRecords/EditAction', component: WorkflowEditActionFindRecords, parameters: { msw: graphqlMocks, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionSendEmail.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionSendEmail.stories.tsx index 175a0ca380..00f07149df 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionSendEmail.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionSendEmail.stories.tsx @@ -66,7 +66,7 @@ const CONFIGURED_ACTION: WorkflowSendEmailAction = { }; const meta: Meta = { - title: 'Modules/Workflow/WorkflowEditActionSendEmail', + title: 'Modules/Workflow/Actions/SendEmail/EditAction', component: WorkflowEditActionSendEmail, parameters: { msw: { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx index 49402b4406..7cb9e77444 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx @@ -49,7 +49,7 @@ const DEFAULT_ACTION = { } satisfies WorkflowUpdateRecordAction; const meta: Meta = { - title: 'Modules/Workflow/WorkflowEditActionUpdateRecord', + title: 'Modules/Workflow/Actions/UpdateRecord/EditAction', component: WorkflowEditActionUpdateRecord, parameters: { msw: graphqlMocks, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestExecutionResult.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestExecutionResult.tsx new file mode 100644 index 0000000000..d9064e0974 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestExecutionResult.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestTestVariableInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestTestVariableInput.tsx new file mode 100644 index 0000000000..42794c81e5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/HttpRequestTestVariableInput.tsx @@ -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 ( + + + {variableArray.map((variablePath) => ( + + handleVariableChange(variablePath, value || '') + } + /> + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx index b0bca41ac8..49f495fb0d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx @@ -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 ( <> + { if (actionOptions.readonly === true) { @@ -72,51 +155,79 @@ export const WorkflowEditActionHttpRequest = ({ disabled={actionOptions.readonly} /> - handleFieldChange('url', value)} - VariablePicker={WorkflowVariablePicker} - /> - handleFieldChange('method', value)} - disabled={actionOptions.readonly} - dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }} - dropdownWidth={GenericDropdownContentWidth.ExtraLarge} - /> + {activeTabId === WorkflowHttpRequestTabId.CONFIGURATION && ( + + handleFieldChange('url', value)} + VariablePicker={WorkflowVariablePicker} + /> + handleFieldChange('method', value)} + disabled={actionOptions.readonly} + dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }} + dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + /> - handleFieldChange('headers', value)} - readonly={actionOptions.readonly} - keyPlaceholder="Header name" - valuePlaceholder="Header value" - /> + handleFieldChange('headers', value)} + readonly={actionOptions.readonly} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + /> - {isMethodWithBody(formData.method) && ( - handleFieldChange('body', value)} - readonly={actionOptions.readonly} - /> + {isMethodWithBody(formData.method) && ( + handleFieldChange('body', value)} + readonly={actionOptions.readonly} + /> + )} + + + + )} + {activeTabId === WorkflowHttpRequestTabId.TEST && ( + + + + )} - - + {activeTabId === WorkflowHttpRequestTabId.TEST && ( + , + ]} + /> + )} > ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/HttpRequestExecutionResult.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/HttpRequestExecutionResult.stories.tsx new file mode 100644 index 0000000000..328e586b94 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/HttpRequestExecutionResult.stories.tsx @@ -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 = { + title: 'Modules/Workflow/Actions/HttpRequest/ExecutionResult', + component: HttpRequestExecutionResult, + decorators: [ComponentDecorator, SnackBarDecorator, I18nFrontDecorator], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/HttpRequestTestVariableInput.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/HttpRequestTestVariableInput.stories.tsx new file mode 100644 index 0000000000..c4d4a0e2d6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/HttpRequestTestVariableInput.stories.tsx @@ -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 = { + title: 'Modules/Workflow/Actions/HttpRequest/TestVariableInput', + component: HttpRequestTestVariableInput, + decorators: [ComponentDecorator], + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx index 20f61e5d48..1254f7418f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx @@ -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 = { - title: 'Modules/Workflow/WorkflowEditActionHttpRequest', + title: 'Modules/Workflow/Actions/HttpRequest/EditAction', component: WorkflowEditActionHttpRequest, parameters: { msw: graphqlMocks, @@ -80,7 +80,7 @@ const meta: Meta = { decorators: [ WorkflowStepActionDrawerDecorator, WorkflowStepDecorator, - ComponentDecorator, + ComponentWithRouterDecorator, SnackBarDecorator, WorkspaceDecorator, I18nFrontDecorator, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts index ef3d85e91a..785e562f4a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts @@ -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 ->; +export type HttpRequestBody = Record; 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, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/WorkflowHttpRequestTabListComponentId.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/WorkflowHttpRequestTabListComponentId.ts new file mode 100644 index 0000000000..501ae9adfa --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/WorkflowHttpRequestTabListComponentId.ts @@ -0,0 +1,2 @@ +export const WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID = + 'workflow-http-request-tab-list'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts new file mode 100644 index 0000000000..670084900c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts @@ -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; + +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' } } }, + }, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts new file mode 100644 index 0000000000..37e0967728 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts @@ -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 => { + const result: Record = {}; + + 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; + } + + 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), + }, + }; + + 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 = {}; + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState.ts new file mode 100644 index 0000000000..7d4e9fa481 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState.ts @@ -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, + }, +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData.ts new file mode 100644 index 0000000000..d71728464e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData.ts @@ -0,0 +1,13 @@ +export type HttpRequestTestData = { + variableValues: { [variablePath: string]: any }; + output: { + data?: string; + status?: number; + statusText?: string; + headers?: Record; + duration?: number; + error?: string; + }; + language: 'plaintext' | 'json'; + height: number; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/types/WorkflowHttpRequestTabId.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/types/WorkflowHttpRequestTabId.ts new file mode 100644 index 0000000000..b701b420f0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/types/WorkflowHttpRequestTabId.ts @@ -0,0 +1,6 @@ +export type WorkflowHttpRequestTabIdType = 'configuration' | 'test'; + +export enum WorkflowHttpRequestTabId { + CONFIGURATION = 'configuration', + TEST = 'test', +} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts index b3ae019d4c..b67a59a7dd 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -121,13 +121,11 @@ export function formatResult( // 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; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job.ts index 6aa49d3c9d..da57b075aa 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job.ts @@ -1,4 +1,3 @@ -// import { Scope } from '@nestjs/common'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts index 58d7e0d0d5..0f48279f90 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts @@ -1,4 +1,3 @@ -// import { Injectable, Logger } from '@nestjs/common'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index 0e141defe6..c6cd5c72fa 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -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'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts index 0a0381b791..6d9f246e1e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts @@ -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'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts index 2faf67c541..715cddf329 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts @@ -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'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts index 3235327bd3..13433317d7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts index a2784d2b55..b08fc29378 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts index bfc18eadd4..aba329eaf1 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts index 838d1b60a9..0867c6056f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts index 6542323ef7..412bf2458c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts @@ -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() diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 2350472e47..dd9c3e14d6 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -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" }, diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 431c0a8be1..f7bd03d0d9 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts b/packages/twenty-shared/src/utils/variable-resolver.test.ts similarity index 96% rename from packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts rename to packages/twenty-shared/src/utils/variable-resolver.test.ts index 4d5634a3c9..d629cca6fe 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts +++ b/packages/twenty-shared/src/utils/variable-resolver.test.ts @@ -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 = { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts b/packages/twenty-shared/src/utils/variable-resolver.ts similarity index 92% rename from packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts rename to packages/twenty-shared/src/utils/variable-resolver.ts index 9709531844..692b90ca7a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts +++ b/packages/twenty-shared/src/utils/variable-resolver.ts @@ -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 = ( diff --git a/yarn.lock b/yarn.lock index e0fac56aa3..3e2397caf9 100644 --- a/yarn.lock +++ b/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"