diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index 37de7d347fa80..01174c399fe09 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -2,6 +2,13 @@ # Changelog +## [2.4.0] - 2025-12-10 + +### Added + +- Added support for `http_request` prop type with `ControlHttpRequest` component +- HTTP request builder UI with URL, method, headers, and body configuration + ## [2.3.0] - 2025-12-07 ### Added diff --git a/packages/connect-react/package.json b/packages/connect-react/package.json index 0c6c3de6408f3..c5b21568664d4 100644 --- a/packages/connect-react/package.json +++ b/packages/connect-react/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/connect-react", - "version": "2.3.0", + "version": "2.4.0", "description": "Pipedream Connect library for React", "files": [ "dist" diff --git a/packages/connect-react/src/components/Control.tsx b/packages/connect-react/src/components/Control.tsx index cc11ad5e781f0..7b8e150b1ab24 100644 --- a/packages/connect-react/src/components/Control.tsx +++ b/packages/connect-react/src/components/Control.tsx @@ -9,6 +9,7 @@ import { ControlApp } from "./ControlApp"; import { ControlArray } from "./ControlArray"; import { ControlBoolean } from "./ControlBoolean"; import { ControlInput } from "./ControlInput"; +import { ControlHttpRequest } from "./ControlHttpRequest"; import { ControlObject } from "./ControlObject"; import { ControlSelect } from "./ControlSelect"; import { ControlSql } from "./ControlSql"; @@ -81,6 +82,8 @@ export function Control return ; case "sql": return ; + case "http_request": + return ; default: // TODO "not supported prop type should bubble up" throw new Error("Unsupported property type: " + prop.type); diff --git a/packages/connect-react/src/components/ControlHttpRequest.tsx b/packages/connect-react/src/components/ControlHttpRequest.tsx new file mode 100644 index 0000000000000..ca54442a88ff9 --- /dev/null +++ b/packages/connect-react/src/components/ControlHttpRequest.tsx @@ -0,0 +1,478 @@ +import { + useState, useEffect, +} from "react"; +import { useFormFieldContext } from "../hooks/form-field-context"; +import { useCustomize } from "../hooks/customization-context"; +import { + getInputStyles, getButtonStyles, getRemoveButtonStyles, getContainerStyles, getItemStyles, +} from "../styles/control-styles"; + +const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", +] as const; + +type KeyValuePair = { + key: string; + value: string; + disabled?: boolean; +}; + +type HttpRequestState = { + url: string; + method: string; + headers: KeyValuePair[]; + params: KeyValuePair[]; + body: string; + bodyContentType: string; +}; + +type RequestKeyValue = { name?: string; value?: string; disabled?: boolean }; + +// This matches the schema expected by the Pipedream component runtime +// See: lambda-v2/packages/component-runtime/src/prepareProps/httpRequest.js +type HttpRequestValue = { + url: string; + method: string; + headers: Array<{ name: string; value: string; disabled?: boolean }>; + params: Array<{ name: string; value: string; disabled?: boolean }>; + body: { + contentType: string; + type: "raw" | "fields"; + mode: "raw" | "fields"; + raw?: string; + fields?: Array<{ name: string; value: string }>; + }; + auth?: { + type: "none" | "basic" | "bearer"; + username?: string; + password?: string; + token?: string; + }; +}; + +const normalizeKeyValuePairs = (items?: RequestKeyValue[]): KeyValuePair[] => { + const normalized = (items ?? []).map((item) => ({ + key: item.name ?? "", + value: item.value ?? "", + disabled: item.disabled, + })); + + return normalized.length > 0 + ? normalized + : [ + { + key: "", + value: "", + }, + ]; +}; + +const serializeKeyValuePairs = (pairs: KeyValuePair[]) => pairs + .filter((pair) => pair.key.trim() !== "") + .map((pair) => { + const serialized: { name: string; value: string; disabled?: boolean } = { + name: pair.key, + value: pair.value, + }; + + if (pair.disabled !== undefined) { + serialized.disabled = pair.disabled; + } + + return serialized; + }); + +const BODY_CONTENT_TYPES = [ + { + value: "application/json", + label: "JSON", + }, + { + value: "application/x-www-form-urlencoded", + label: "Form URL Encoded", + }, + { + value: "text/plain", + label: "Text", + }, + { + value: "none", + label: "None", + }, +] as const; + +export function ControlHttpRequest() { + const formFieldContextProps = useFormFieldContext(); + const { + onChange, prop, value, + } = formFieldContextProps; + const { + getProps, theme, + } = useCustomize(); + + // Get default values from prop definition + const getDefaultConfig = () => { + if ("default" in prop && prop.default && typeof prop.default === "object") { + return prop.default as { + url?: string; + method?: string; + headers?: RequestKeyValue[]; + params?: RequestKeyValue[]; + body?: { raw?: string; contentType?: string }; + }; + } + return undefined; + }; + + // Initialize headers from value or defaults + const initializeHeaders = (): KeyValuePair[] => { + const currentValue = value as HttpRequestValue | undefined; + const defaultConfig = getDefaultConfig(); + + return normalizeKeyValuePairs(currentValue?.headers ?? defaultConfig?.headers); + }; + + // Initialize params from value or defaults + const initializeParams = (): KeyValuePair[] => { + const currentValue = value as HttpRequestValue | undefined; + const defaultConfig = getDefaultConfig(); + + return normalizeKeyValuePairs(currentValue?.params ?? defaultConfig?.params); + }; + + // Initialize body from value or defaults + const initializeBody = (): string => { + const currentValue = value as HttpRequestValue | undefined; + const defaultConfig = getDefaultConfig(); + + return currentValue?.body?.raw ?? defaultConfig?.body?.raw ?? ""; + }; + + // Initialize body content type from value or defaults + const initializeBodyContentType = (): string => { + const currentValue = value as HttpRequestValue | undefined; + const defaultConfig = getDefaultConfig(); + + return currentValue?.body?.contentType ?? defaultConfig?.body?.contentType ?? "application/json"; + }; + + // Initialize state + const initializeState = (): HttpRequestState => { + const currentValue = value as HttpRequestValue | undefined; + const defaultConfig = getDefaultConfig(); + + return { + url: currentValue?.url ?? defaultConfig?.url ?? "", + method: currentValue?.method ?? defaultConfig?.method ?? "GET", + headers: initializeHeaders(), + params: initializeParams(), + body: initializeBody(), + bodyContentType: initializeBodyContentType(), + }; + }; + + const [ + state, + setState, + ] = useState(initializeState); + + // Update state when external value changes + useEffect(() => { + const currentValue = value as HttpRequestValue | undefined; + const defaultConfig = getDefaultConfig(); + + const headers = normalizeKeyValuePairs(currentValue?.headers ?? defaultConfig?.headers); + + const params = normalizeKeyValuePairs(currentValue?.params ?? defaultConfig?.params); + + setState({ + url: currentValue?.url ?? defaultConfig?.url ?? "", + method: currentValue?.method ?? defaultConfig?.method ?? "GET", + headers, + params, + body: currentValue?.body?.raw ?? defaultConfig?.body?.raw ?? "", + bodyContentType: currentValue?.body?.contentType ?? defaultConfig?.body?.contentType ?? "application/json", + }); + }, [ + value, + ]); + + // Serialize state to output format that matches the backend schema + // IMPORTANT: The backend requires headers and params to ALWAYS be arrays (even if empty) + // See: lambda-v2/packages/component-runtime/src/prepareProps/httpRequest.js + const serializeToOutput = (currentState: HttpRequestState): HttpRequestValue => { + // Filter out empty headers but keep the array structure + const validHeaders = serializeKeyValuePairs(currentState.headers); + + // Filter out empty params but keep the array structure + const validParams = serializeKeyValuePairs(currentState.params); + + // Build body object - contentType is always required + const body: HttpRequestValue["body"] = { + contentType: currentState.bodyContentType, + type: "raw", + mode: "raw", + }; + + // Only include raw if there's content and contentType isn't "none" + if (currentState.bodyContentType !== "none" && currentState.body.trim()) { + body.raw = currentState.body; + } + + const output: HttpRequestValue = { + url: currentState.url, + method: currentState.method, + headers: validHeaders, // Always an array, even if empty + params: validParams, // Always an array, even if empty + body, + }; + + return output; + }; + + const handleUrlChange = (url: string) => { + const newState = { + ...state, + url, + }; + setState(newState); + onChange(serializeToOutput(newState)); + }; + + const handleMethodChange = (method: string) => { + const newState = { + ...state, + method, + }; + setState(newState); + onChange(serializeToOutput(newState)); + }; + + const handleHeaderChange = (index: number, field: "key" | "value", newValue: string) => { + const newHeaders = [ + ...state.headers, + ]; + newHeaders[index] = { + ...newHeaders[index], + [field]: newValue, + }; + const newState = { + ...state, + headers: newHeaders, + }; + setState(newState); + onChange(serializeToOutput(newState)); + }; + + const addHeader = () => { + const newHeaders = [ + ...state.headers, + { + key: "", + value: "", + }, + ]; + setState({ + ...state, + headers: newHeaders, + }); + }; + + const removeHeader = (index: number) => { + const newHeaders = state.headers.filter((_, i) => i !== index); + const finalHeaders = newHeaders.length > 0 + ? newHeaders + : [ + { + key: "", + value: "", + }, + ]; + const newState = { + ...state, + headers: finalHeaders, + }; + setState(newState); + onChange(serializeToOutput(newState)); + }; + + const handleBodyContentTypeChange = (bodyContentType: string) => { + const newState = { + ...state, + bodyContentType, + }; + setState(newState); + onChange(serializeToOutput(newState)); + }; + + const handleBodyChange = (body: string) => { + const newState = { + ...state, + body, + }; + setState(newState); + onChange(serializeToOutput(newState)); + }; + + const containerStyles = getContainerStyles(); + const itemStyles = getItemStyles(); + const inputStyles = getInputStyles(theme); + const buttonStyles = getButtonStyles(theme); + const removeButtonStyles = getRemoveButtonStyles(theme); + + const sectionStyles = { + display: "flex" as const, + flexDirection: "column" as const, + gap: `${theme.spacing.baseUnit}px`, + }; + + const labelStyles = { + fontSize: "0.75rem", + fontWeight: 500, + color: theme.colors.neutral70, + }; + + const selectStyles = { + ...inputStyles, + cursor: "pointer" as const, + }; + + const methodSelectStyles = { + ...inputStyles, + cursor: "pointer" as const, + flex: "none", + width: "85px", + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + borderRight: "none", + }; + + const urlInputStyles = { + ...inputStyles, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + flex: 1, + }; + + const textareaStyles = { + ...inputStyles, + resize: "vertical" as const, + minHeight: "80px", + fontFamily: "monospace", + }; + + const urlRowStyles = { + display: "flex" as const, + alignItems: "stretch" as const, + }; + + const fieldId = `http-request-${prop.name}`; + + return ( +
+ {/* URL + Method Section */} +
+ URL +
+ + handleUrlChange(e.target.value)} + placeholder="https://api.example.com/endpoint" + style={urlInputStyles} + required={!prop.optional} + aria-label="URL" + /> +
+
+ + {/* Headers Section */} +
+ Headers + {state.headers.map((header, index) => ( +
+ handleHeaderChange(index, "key", e.target.value)} + placeholder="Header name" + style={inputStyles} + aria-label={`Header ${index + 1} name`} + /> + handleHeaderChange(index, "value", e.target.value)} + placeholder="Header value" + style={inputStyles} + aria-label={`Header ${index + 1} value`} + /> + {state.headers.length > 1 && ( + + )} +
+ ))} + +
+ + {/* Body Section */} +
+ + + {state.bodyContentType !== "none" && ( +