From 39326e6144b4fd79b8e9e48ea457e5783163dcc7 Mon Sep 17 00:00:00 2001 From: Naifer <161821705+omarNaifer12@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:32:19 +0100 Subject: [PATCH] feat: add FormData, text, and none body types to HTTP request workflow (#14055) This PR is a cleaned-up and improved version of #13982, excluding file uploads for FormData. https://github.com/user-attachments/assets/a85546f6-4e3b-4654-a4ea-e76a11016b96 --------- Co-authored-by: Thomas Trompette --- .../components/BodyInput.tsx | 118 ++++++++++++++---- .../components/KeyValuePairInput.tsx | 64 +++++++--- .../WorkflowEditActionHttpRequest.tsx | 9 +- .../WorkflowEditActionHttpRequest.stories.tsx | 10 +- .../constants/AutoSetHeaderKeys.ts | 1 + .../constants/HttpRequest.ts | 9 ++ .../hooks/useHttpRequestForm.ts | 20 ++- .../hooks/useTestHttpRequest.ts | 118 +++++++++++------- .../utils/getBodyTypeFromHeaders.ts | 23 ++++ .../utils/isReadOnlyHeaderKey.ts | 5 + .../tool/tools/http-tool/http-tool.ts | 16 ++- .../constants/contentTypeValuesHttpRequest.ts | 9 ++ packages/twenty-shared/src/workflow/index.ts | 47 ++----- .../workflow/types/workflowHttpRequestStep.ts | 1 + .../utils/parseDataFromContentType.ts | 69 ++++++++++ 15 files changed, 371 insertions(+), 148 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/AutoSetHeaderKeys.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/getBodyTypeFromHeaders.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/isReadOnlyHeaderKey.ts create mode 100644 packages/twenty-shared/src/workflow/constants/contentTypeValuesHttpRequest.ts create mode 100644 packages/twenty-shared/src/workflow/types/workflowHttpRequestStep.ts create mode 100644 packages/twenty-shared/src/workflow/utils/parseDataFromContentType.ts 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 7e1a4f58cd..e21065351b 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 @@ -1,19 +1,25 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer'; import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput'; - +import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { InputLabel } from '@/ui/input/components/InputLabel'; import { Select } from '@/ui/input/components/Select'; import { + BODY_TYPES, DEFAULT_JSON_BODY_PLACEHOLDER, type HttpRequestBody, + type HttpRequestFormData, } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; +import { getBodyTypeFromHeaders } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/getBodyTypeFromHeaders'; 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 { isDefined, parseJson } from 'twenty-shared/utils'; +import { + CONTENT_TYPE_VALUES_HTTP_REQUEST, + type BodyType, +} from 'twenty-shared/workflow'; import { IconFileText, IconKey } from 'twenty-ui/display'; import { type JsonValue } from 'type-fest'; import { KeyValuePairInput } from './KeyValuePairInput'; @@ -27,32 +33,55 @@ const StyledContainer = styled.div` const StyledSelectDropdown = styled(Select)` margin-bottom: ${({ theme }) => theme.spacing(2)}; `; +const StyledNoBodyMessage = styled.div` + font-size: ${({ theme }) => theme.font.size.md}; + padding: ${({ theme }) => theme.spacing(2)}; + text-align: left; +`; type BodyInputProps = { label?: string; defaultValue?: HttpRequestBody | string; - onChange: (value?: string) => void; + onChange: ( + value?: string | Record, + type?: keyof HttpRequestFormData, + ) => void; readonly?: boolean; + headers?: Record; }; export const BodyInput = ({ defaultValue, onChange, readonly, + headers, }: BodyInputProps) => { const defaultValueParsed = isString(defaultValue) ? (parseJson(defaultValue) ?? {}) : defaultValue; - const [isRawJson, setIsRawJson] = useState( - shouldDisplayRawJsonByDefault(defaultValue), - ); + const isBodyTypeRawJson = + getBodyTypeFromHeaders(headers) === BODY_TYPES.RAW_JSON; const [jsonString, setJsonString] = useState( - isString(defaultValue) - ? defaultValue - : JSON.stringify(defaultValue, null, 2), + isBodyTypeRawJson + ? isString(defaultValue) + ? defaultValue + : JSON.stringify(defaultValue, null, 2) + : null, ); const [errorMessage, setErrorMessage] = useState(); + const [textValue, setTextValue] = useState( + getBodyTypeFromHeaders(headers) === BODY_TYPES.TEXT && + isString(defaultValue) + ? defaultValue + : undefined, + ); + + const handleChangeTextValue = (text: string) => { + setTextValue(text); + + onChange(text); + }; const validateJson = (value: string | null): boolean => { if (!value?.trim()) { @@ -93,15 +122,37 @@ export const BodyInput = ({ // Do nothing, validation will happen on blur } }; - - const handleModeChange = (isRawJson: boolean) => { - setIsRawJson(isRawJson); - onChange(); - setJsonString(null); + const resetBodyStateValue = (bodyTypeValue: BodyType) => { + if (bodyTypeValue === BODY_TYPES.RAW_JSON && isDefined(jsonString)) { + setJsonString(null); + setErrorMessage(undefined); + } else if (bodyTypeValue === BODY_TYPES.TEXT && isDefined(textValue)) { + setTextValue(undefined); + } + }; + const handleModeChange = (bodyTypeValue: BodyType) => { + if ( + bodyTypeValue === BODY_TYPES.NONE && + isDefined(headers?.['content-type']) + ) { + const headersCopy = { ...headers }; + delete headersCopy['content-type']; + onChange(headersCopy, 'headers'); + } else if ( + bodyTypeValue !== BODY_TYPES.NONE && + getBodyTypeFromHeaders(headers) !== bodyTypeValue + ) { + const newHeaders = { + ...headers, + 'content-type': CONTENT_TYPE_VALUES_HTTP_REQUEST[bodyTypeValue], + }; + resetBodyStateValue(bodyTypeValue); + onChange(newHeaders, 'headers'); + } }; const handleBlur = () => { - if (isRawJson && Boolean(jsonString)) { + if (isBodyTypeRawJson) { validateJson(jsonString); } }; @@ -111,17 +162,20 @@ export const BodyInput = ({ Body Input handleModeChange(value === 'rawJson')} + value={getBodyTypeFromHeaders(headers) || BODY_TYPES.NONE} + onChange={(value) => handleModeChange(value as BodyType)} disabled={readonly} /> - {isRawJson ? ( + {isBodyTypeRawJson ? ( - ) : ( + ) : getBodyTypeFromHeaders(headers) === BODY_TYPES.KEY_VALUE ? ( } onChange={handleKeyValueChange} readonly={readonly} keyPlaceholder="Property name" valuePlaceholder="Property value" /> + ) : getBodyTypeFromHeaders(headers) === BODY_TYPES.FORM_DATA ? ( + } + onChange={handleKeyValueChange} + readonly={readonly} + keyPlaceholder="Property name" + valuePlaceholder="Property value" + /> + ) : getBodyTypeFromHeaders(headers) === BODY_TYPES.TEXT ? ( + handleChangeTextValue(value)} + VariablePicker={WorkflowVariablePicker} + /> + ) : ( + No body )} 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 0e377ce1b0..2fc1b0a363 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 @@ -1,6 +1,8 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { InputLabel } from '@/ui/input/components/InputLabel'; +import { AUTO_SET_HEADER_KEYS } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/AutoSetHeaderKeys'; +import { isAutoSetHeaderKey } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isReadOnlyHeaderKey'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; @@ -28,11 +30,11 @@ const StyledKeyValueContainer = styled.div<{ readonly: boolean | undefined }>` grid-template-columns: repeat(2, minmax(0, 1fr)) ${theme.spacing(8)}; `}; `; - export type KeyValuePair = { id: string; key: string; value: string; + isAutoSet: boolean; }; export type KeyValuePairInputProps = { @@ -46,23 +48,36 @@ export type KeyValuePairInputProps = { export const KeyValuePairInput = ({ label, - defaultValue = {}, onChange, readonly, keyPlaceholder = 'Key', valuePlaceholder = 'Value', + defaultValue, }: KeyValuePairInputProps) => { const [pairs, setPairs] = useState(() => { - const initialPairs = Object.entries(defaultValue).map(([key, value]) => ({ - id: v4(), - key, - value, - })); + const initialPairs = defaultValue + ? Object.entries(defaultValue).map(([key, value]) => ({ + id: v4(), + key, + value, + isAutoSet: isAutoSetHeaderKey(key), + })) + : []; return initialPairs.length > 0 - ? [...initialPairs, { id: v4(), key: '', value: '' }] - : [{ id: v4(), key: '', value: '' }]; + ? [...initialPairs, { id: v4(), key: '', value: '', isAutoSet: false }] + : [{ id: v4(), key: '', value: '', isAutoSet: false }]; }); + const getKeyValidationError = (pair: KeyValuePair): string | undefined => { + const trimmedKey = pair.key.trim(); + + if (!pair.isAutoSet && isAutoSetHeaderKey(trimmedKey)) { + return 'This key should be set through body input'; + } + + return undefined; + }; + const handlePairChange = ( pairId: string, field: 'key' | 'value', @@ -70,6 +85,7 @@ export const KeyValuePairInput = ({ ) => { const index = pairs.findIndex((p) => p.id === pairId); const newPairs = [...pairs]; + newPairs[index] = { ...newPairs[index], [field]: newValue }; if ( @@ -77,15 +93,18 @@ export const KeyValuePairInput = ({ (field === 'key' || field === 'value') && Boolean(newValue.trim()) ) { - newPairs.push({ id: v4(), key: '', value: '' }); + newPairs.push({ id: v4(), key: '', value: '', isAutoSet: false }); } setPairs(newPairs); const record = newPairs.reduce( - (acc, { key, value }) => { - if (key.trim().length > 0) { - acc[key] = value; + (acc, pair) => { + if (!pair.isAutoSet && isAutoSetHeaderKey(pair.key)) { + return acc; + } + if (pair.key.trim().length > 0) { + acc[pair.key] = pair.value; } return acc; }, @@ -97,8 +116,14 @@ export const KeyValuePairInput = ({ const handleRemovePair = (pairId: string) => { const newPairs = pairs.filter((pair) => pair.id !== pairId); - if (newPairs.length === 0) { - newPairs.push({ id: v4(), key: '', value: '' }); + if ( + newPairs.length === 0 || + (newPairs.length === AUTO_SET_HEADER_KEYS?.length && + newPairs.every((pair) => + AUTO_SET_HEADER_KEYS.includes(pair.key.trim()), + )) + ) { + newPairs.push({ id: v4(), key: '', value: '', isAutoSet: false }); } setPairs(newPairs); @@ -123,17 +148,18 @@ export const KeyValuePairInput = ({ handlePairChange(pair.id, 'key', value ?? '') } VariablePicker={WorkflowVariablePicker} + error={getKeyValidationError(pair)} /> handlePairChange(pair.id, 'value', value ?? '') @@ -141,7 +167,9 @@ export const KeyValuePairInput = ({ VariablePicker={WorkflowVariablePicker} /> - {!readonly && pair.id !== pairs[pairs.length - 1].id ? ( + {!readonly && + pair.id !== pairs[pairs.length - 1].id && + !pair.isAutoSet ? (