diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index 15460be41..5ba7dc7e2 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -126,6 +126,7 @@ export const workflowHttpRequestActionSettingsSchema = z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), ]), ) + .or(z.string()) .optional(), }), }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx index 35255eebb..d574d3506 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx @@ -7,11 +7,15 @@ import { DEFAULT_JSON_BODY_PLACEHOLDER, HttpRequestBody, } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; -import { hasNonStringValues } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues'; +import { parseHttpJsonBodyWithoutVariablesOrThrow } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow'; +import { shouldDisplayRawJsonByDefault } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import styled from '@emotion/styled'; +import { isString } from '@sniptt/guards'; import { useState } from 'react'; +import { parseJson } from 'twenty-shared/utils'; import { IconFileText, IconKey } from 'twenty-ui/display'; +import { JsonValue } from 'type-fest'; import { KeyValuePairInput } from './KeyValuePairInput'; const StyledContainer = styled.div` @@ -26,8 +30,8 @@ const StyledSelectDropdown = styled(Select)` type BodyInputProps = { label?: string; - defaultValue?: HttpRequestBody; - onChange: (value?: HttpRequestBody) => void; + defaultValue?: HttpRequestBody | string; + onChange: (value?: string) => void; readonly?: boolean; }; @@ -36,11 +40,17 @@ export const BodyInput = ({ onChange, readonly, }: BodyInputProps) => { - const [isRawJson, setIsRawJson] = useState(() => - hasNonStringValues(defaultValue), + const defaultValueParsed = isString(defaultValue) + ? (parseJson(defaultValue) ?? {}) + : defaultValue; + + const [isRawJson, setIsRawJson] = useState( + shouldDisplayRawJsonByDefault(defaultValue), ); const [jsonString, setJsonString] = useState( - JSON.stringify(defaultValue, null, 2), + isString(defaultValue) + ? defaultValue + : JSON.stringify(defaultValue, null, 2), ); const [errorMessage, setErrorMessage] = useState(); @@ -51,7 +61,8 @@ export const BodyInput = ({ } try { - JSON.parse(value); + parseHttpJsonBodyWithoutVariablesOrThrow(value); + setErrorMessage(undefined); return true; } catch (e) { @@ -61,7 +72,7 @@ export const BodyInput = ({ }; const handleKeyValueChange = (value: Record) => { - onChange(value); + onChange(JSON.stringify(value, null, 2)); setErrorMessage(undefined); }; @@ -75,8 +86,9 @@ export const BodyInput = ({ } try { - const parsed = JSON.parse(value); - onChange(parsed); + parseHttpJsonBodyWithoutVariablesOrThrow(value); + + onChange(value); } catch { // Do nothing, validation will happen on blur } @@ -121,7 +133,7 @@ export const BodyInput = ({ /> ) : ( } + defaultValue={defaultValueParsed as Record} onChange={handleKeyValueChange} readonly={readonly} keyPlaceholder="Property name" diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx index 537ab3b40..3d668ac21 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx @@ -39,7 +39,7 @@ export type KeyValuePair = { export type KeyValuePairInputProps = { label?: string; - defaultValue?: Record; + defaultValue?: Record | Array; onChange: (value: Record) => void; readonly?: boolean; keyPlaceholder?: string; 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 017dda41b..20f61e5d4 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,6 +1,6 @@ import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow'; import { Meta, StoryObj } from '@storybook/react'; -import { expect, fn, within } from '@storybook/test'; +import { expect, fn, waitFor, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui/testing'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -8,7 +8,10 @@ import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/Workflow import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; -import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; +import { + getWorkflowNodeIdMock, + MOCKED_STEP_ID, +} from '~/testing/mock-data/workflow'; import { WorkflowEditActionHttpRequest } from '../WorkflowEditActionHttpRequest'; const DEFAULT_ACTION: WorkflowHttpRequestAction = { @@ -149,3 +152,208 @@ export const ReadOnly: Story = { expect(methodSelect).toBeVisible(); }, }; + +export const WithArrayStringBody: Story = { + args: { + action: { + id: getWorkflowNodeIdMock(), + name: 'API Call with Array', + type: 'HTTP_REQUEST', + valid: true, + settings: { + input: { + url: 'https://api.example.com/tags', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: `[ + "frontend", + "backend", + "database" +]`, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + } satisfies WorkflowHttpRequestAction, + actionOptions: { + onActionUpdate: fn(), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Key/Value')).toBeVisible(); + + expect(await canvas.findByText('0')).toBeVisible(); + expect(await canvas.findByText('1')).toBeVisible(); + expect(await canvas.findByText('2')).toBeVisible(); + + expect(await canvas.findByText('frontend')).toBeVisible(); + expect(await canvas.findByText('backend')).toBeVisible(); + expect(await canvas.findByText('database')).toBeVisible(); + }, +}; + +export const WithObjectStringBody: Story = { + args: { + action: { + id: getWorkflowNodeIdMock(), + name: 'API Call with Array', + type: 'HTTP_REQUEST', + valid: true, + settings: { + input: { + url: 'https://api.example.com/tags', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: `{ + "hey": "frontend", + "oh": "backend", + "amazing": "database {{${MOCKED_STEP_ID}.salary}}" +}`, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + } satisfies WorkflowHttpRequestAction, + actionOptions: { + onActionUpdate: fn(), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Key/Value')).toBeVisible(); + + const textboxes = await waitFor(() => { + const elements = canvas.getAllByRole('textbox'); + expect(elements.length).toBe(14); + return elements; + }); + + expect(textboxes[5]).toHaveTextContent('hey'); + expect(textboxes[7]).toHaveTextContent('oh'); + expect(textboxes[9]).toHaveTextContent('amazing'); + + expect(textboxes[6]).toHaveTextContent('frontend'); + expect(textboxes[8]).toHaveTextContent('backend'); + expect(textboxes[10]).toHaveTextContent('database Salary'); + }, +}; + +export const WithArrayContainingNonStringVariablesBody: Story = { + args: { + action: { + id: getWorkflowNodeIdMock(), + name: 'API Call with Array', + type: 'HTTP_REQUEST', + valid: true, + settings: { + input: { + url: 'https://api.example.com/tags', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: `[ + "frontend", + {{${MOCKED_STEP_ID}.salary}}, + "database" +]`, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + } satisfies WorkflowHttpRequestAction, + actionOptions: { + onActionUpdate: fn(), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Raw JSON')).toBeVisible(); + + await waitFor(() => { + const textboxes = canvas.getAllByRole('textbox'); + + expect(textboxes[5]).toHaveTextContent( + '[ "frontend", Salary, "database"]', + ); + }); + }, +}; + +export const WithObjectContainingNonStringVariablesBody: Story = { + args: { + action: { + id: getWorkflowNodeIdMock(), + name: 'API Call with Array', + type: 'HTTP_REQUEST', + valid: true, + settings: { + input: { + url: 'https://api.example.com/tags', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: `{ + "speciality": "frontend", + "salary": {{${MOCKED_STEP_ID}.salary}} +}`, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + } satisfies WorkflowHttpRequestAction, + actionOptions: { + onActionUpdate: fn(), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Raw JSON')).toBeVisible(); + + await waitFor(() => { + const textboxes = canvas.getAllByRole('textbox'); + + expect(textboxes[5]).toHaveTextContent( + '{ "speciality": "frontend", "salary": Salary}', + ); + }); + }, +}; 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 e591a6554..ef3d85e91 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 @@ -21,7 +21,7 @@ export type HttpRequestFormData = { url: string; method: HttpMethod; headers: Record; - body?: HttpRequestBody; + body?: HttpRequestBody | string; }; export const DEFAULT_JSON_BODY_PLACEHOLDER = diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts index 0265d8bb8..bc0a127ae 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts @@ -2,21 +2,17 @@ import { HttpRequestBody } from '../../constants/HttpRequest'; import { hasNonStringValues } from '../hasNonStringValues'; describe('hasNonStringValues', () => { - it('should return false for undefined input', () => { - expect(hasNonStringValues(undefined)).toBe(false); - }); - - it('should return false for empty object', () => { - expect(hasNonStringValues({})).toBe(false); + it('should return true for empty object', () => { + expect(hasNonStringValues({})).toBe(true); }); it('should return false for object with only string values', () => { expect(hasNonStringValues({ key1: 'value1', key2: 'value2' })).toBe(false); }); - it('should return false for object with null values', () => { + it('should return true for object with null values', () => { const body: HttpRequestBody = { key1: null, key2: 'value' }; - expect(hasNonStringValues(body)).toBe(false); + expect(hasNonStringValues(body)).toBe(true); }); it('should return true for object with number values', () => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts index 4c0af52b4..a34893ee5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts @@ -1,11 +1,12 @@ -import { HttpRequestBody } from '../constants/HttpRequest'; +import { isString } from '@sniptt/guards'; +import { JsonArray, JsonObject } from 'type-fest'; -export const hasNonStringValues = (obj?: HttpRequestBody): boolean => { - if (!obj) { - return false; +export const hasNonStringValues = (obj: JsonObject | JsonArray): boolean => { + const values = Object.values(obj); + + if (values.length === 0) { + return true; } - return Object.values(obj).some( - (value) => - value !== null && value !== undefined && typeof value !== 'string', - ); + + return !values.every((value) => isString(value)); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts new file mode 100644 index 000000000..39ba2a822 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts @@ -0,0 +1,5 @@ +import { removeVariablesFromJson } from '@/workflow/workflow-variables/utils/removeVariablesFromJson'; + +export const parseHttpJsonBodyWithoutVariablesOrThrow = (value: string) => { + return JSON.parse(removeVariablesFromJson(value)); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts new file mode 100644 index 000000000..95bd45af1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts @@ -0,0 +1,30 @@ +import { HttpRequestBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; +import { hasNonStringValues } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues'; +import { removeVariablesFromJson } from '@/workflow/workflow-variables/utils/removeVariablesFromJson'; +import { isObject, isString, isUndefined } from '@sniptt/guards'; +import { parseJson } from 'twenty-shared/utils'; +import { JsonValue } from 'type-fest'; + +export const shouldDisplayRawJsonByDefault = ( + defaultValue: string | HttpRequestBody | undefined, +) => { + const defaultValueParsedWithoutVariables: JsonValue | undefined = isString( + defaultValue, + ) + ? (parseJson(removeVariablesFromJson(defaultValue)) ?? {}) + : defaultValue; + + if (isUndefined(defaultValueParsedWithoutVariables)) { + return false; + } + + return ( + ((isObject(defaultValueParsedWithoutVariables) || + Array.isArray(defaultValueParsedWithoutVariables)) && + hasNonStringValues(defaultValueParsedWithoutVariables)) || + !( + isObject(defaultValueParsedWithoutVariables) || + Array.isArray(defaultValueParsedWithoutVariables) + ) + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts new file mode 100644 index 000000000..3cd60ec75 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts @@ -0,0 +1,5 @@ +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex'; + +export const removeVariablesFromJson = (json: string): string => { + return json.replaceAll(CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, 'null'); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts index cf3aeb9da..181d03468 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { isString } from '@sniptt/guards'; import axios, { AxiosRequestConfig } from 'axios'; import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; @@ -52,7 +53,9 @@ export class HttpRequestWorkflowAction implements WorkflowExecutor { }; if (['POST', 'PUT', 'PATCH'].includes(method) && body) { - axiosConfig.data = body; + const parsedBody = isString(body) ? JSON.parse(body) : body; + + axiosConfig.data = parsedBody; } const response = await axios(axiosConfig); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts index 179443abd..078fb81a3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts @@ -2,13 +2,15 @@ export type WorkflowHttpRequestActionInput = { url: string; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record; - body?: Record< - string, - | string - | number - | boolean - | null - | undefined - | Array - >; + body?: + | Record< + string, + | string + | number + | boolean + | null + | undefined + | Array + > + | string; };