diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index fcc312f665098..3cd4c9f4dd313 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -11,6 +11,9 @@ import { useFormFieldContext } from "../hooks/form-field-context"; import { useCustomize } from "../hooks/customization-context"; import type { BaseReactSelectProps } from "../hooks/customization-context"; import { LoadMoreButton } from "./LoadMoreButton"; +import { + isOptionWithValue, OptionWithValue, sanitizeOption, +} from "../utils/type-guards"; // XXX T and ConfigurableProp should be related type ControlSelectProps = { @@ -41,10 +44,11 @@ export function ControlSelect({ ] = useState(value); useEffect(() => { - setSelectOptions(options) + const sanitizedOptions = options.map(sanitizeOption); + setSelectOptions(sanitizedOptions); }, [ options, - ]) + ]); useEffect(() => { setRawValue(value) @@ -67,11 +71,11 @@ export function ControlSelect({ if (ret != null) { if (Array.isArray(ret)) { // if simple, make lv (XXX combine this with other place this happens) - if (typeof ret[0] !== "object") { + if (!isOptionWithValue(ret[0])) { const lvs = []; for (const o of ret) { let obj = { - label: o, + label: String(o), value: o, } for (const item of selectOptions) { @@ -84,8 +88,11 @@ export function ControlSelect({ } ret = lvs; } - } else if (typeof ret !== "object") { - const lvOptions = selectOptions?.[0] && typeof selectOptions[0] === "object"; + } else if (ret && typeof ret === "object" && "__lv" in ret) { + // Extract the actual option from __lv wrapper + ret = ret.__lv; + } else if (!isOptionWithValue(ret)) { + const lvOptions = selectOptions?.[0] && isOptionWithValue(selectOptions[0]); if (lvOptions) { for (const item of selectOptions) { if (item.value === rawValue) { @@ -95,12 +102,10 @@ export function ControlSelect({ } } else { ret = { - label: rawValue, + label: String(rawValue), value: rawValue, } } - } else if (ret.__lv) { - ret = ret.__lv } } return ret; @@ -117,13 +122,14 @@ export function ControlSelect({ { children }
- + {})}/>
) } const props = select.getProps("controlSelect", baseSelectProps) + if (showLoadMoreButton) { props.components = { // eslint-disable-next-line react/prop-types @@ -133,24 +139,26 @@ export function ControlSelect({ } const handleCreate = (inputValue: string) => { - const createOption = (input: unknown) => { - if (typeof input === "object") return input + const createOption = (input: unknown): OptionWithValue => { + if (isOptionWithValue(input)) return input + const strValue = String(input); return { - label: input, - value: input, + label: strValue, + value: strValue, } } const newOption = createOption(inputValue) let newRawValue = newOption - const newSelectOptions = selectOptions - ? [ - newOption, - ...selectOptions, - ] - : [ - newOption, - ] + + // NEVER add wrapped objects to selectOptions - only clean {label, value} objects + const cleanSelectOptions = selectOptions.map(sanitizeOption); + + const newSelectOptions = [ + newOption, + ...cleanSelectOptions, + ]; setSelectOptions(newSelectOptions); + if (prop.type.endsWith("[]")) { if (Array.isArray(rawValue)) { newRawValue = [ @@ -170,14 +178,14 @@ export function ControlSelect({ const handleChange = (o: unknown) => { if (o) { if (Array.isArray(o)) { - if (typeof o[0] === "object" && "value" in o[0]) { + if (typeof o[0] === "object" && o[0] && "value" in o[0]) { onChange({ __lv: o, }); } else { onChange(o); } - } else if (typeof o === "object" && "value" in o) { + } else if (typeof o === "object" && o && "value" in o) { onChange({ __lv: o, }); @@ -198,19 +206,33 @@ export function ControlSelect({ const MaybeCreatableSelect = isCreatable ? CreatableSelect : Select; + + // Final safety check - ensure NO __lv wrapped objects reach react-select + const cleanedOptions = selectOptions.map(sanitizeOption); + return ( { + return typeof option === "string" + ? option + : String(option?.label || option?.value || ""); + }} + getOptionValue={(option) => { + return typeof option === "string" + ? option + : String(option?.value || ""); + }} + onChange={handleChange} {...props} {...selectProps} {...additionalProps} - onChange={handleChange} /> ); } diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index 5dd526f79265b..d7a6455f98cfb 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -5,6 +5,7 @@ import { useFormContext } from "../hooks/form-context"; import { useFormFieldContext } from "../hooks/form-field-context"; import { useFrontendClient } from "../hooks/frontend-client-context"; import { ControlSelect } from "./ControlSelect"; +import { isString } from "../utils/type-guards"; export type RemoteOptionsContainerProps = { queryEnabled?: boolean; @@ -138,9 +139,16 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP const newOptions = [] const allValues = new Set(pageable.values) for (const o of _options || []) { - const value = typeof o === "string" - ? o - : o.value + let value: string | number; + if (isString(o)) { + value = o; + } else if (o && typeof o === "object" && "value" in o && o.value != null) { + value = o.value; + } else { + // Skip items that don't match expected format + console.warn("Skipping invalid option:", o); + continue; + } if (allValues.has(value)) { continue } diff --git a/packages/connect-react/src/index.ts b/packages/connect-react/src/index.ts index b8ff8968f8893..93bd484e2b8fb 100644 --- a/packages/connect-react/src/index.ts +++ b/packages/connect-react/src/index.ts @@ -31,3 +31,9 @@ export * from "./hooks/use-app"; export * from "./hooks/use-apps"; export * from "./hooks/use-component"; export * from "./hooks/use-components"; + +// Debug info for development - consumers can choose to log this if needed +export const DEBUG_INFO = { + buildTime: new Date().toISOString(), + source: "local-development", +}; diff --git a/packages/connect-react/src/utils/type-guards.ts b/packages/connect-react/src/utils/type-guards.ts new file mode 100644 index 0000000000000..1dd408408ffe3 --- /dev/null +++ b/packages/connect-react/src/utils/type-guards.ts @@ -0,0 +1,175 @@ +/** + * Represents an option object with a value and optional label. + * Used by react-select and similar components. + */ +export interface OptionWithValue { + /** The actual value of the option (string or number) */ + value: string | number; + /** Optional display label for the option */ + label?: string; + /** Internal wrapper object (used by form handling logic) */ + __lv?: unknown; +} + +/** + * Type guard to check if a value is a string. + * @param value - The value to check + * @returns true if the value is a string + */ +export function isString(value: unknown): value is string { + return typeof value === "string"; +} + +/** + * Type guard to check if a value is a valid OptionWithValue object. + * Validates that the object has a 'value' property that is either a string or number. + * @param value - The value to check + * @returns true if the value is a valid OptionWithValue + */ +export function isOptionWithValue(value: unknown): value is OptionWithValue { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + "value" in value && + (typeof (value as Record).value === "string" || typeof (value as Record).value === "number") + ); +} + +/** + * Type guard to check if a value is an array of strings. + * @param value - The value to check + * @returns true if the value is a string array + */ +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +/** + * Type guard to check if a value is an array of OptionWithValue objects. + * @param value - The value to check + * @returns true if the value is an array of valid OptionWithValue objects + */ +export function isOptionArray(value: unknown): value is OptionWithValue[] { + return Array.isArray(value) && value.every((item) => isOptionWithValue(item)); +} + +/** + * Normalizes an unknown value into either a string or OptionWithValue. + * Used for basic option processing where the input format is uncertain. + * @param option - The option to normalize + * @returns A normalized string or OptionWithValue object + */ +export function normalizeOption(option: unknown): OptionWithValue | string { + if (isString(option)) { + return option; + } + if (isOptionWithValue(option)) { + return option; + } + return String(option); +} + +/** + * Normalizes an array of unknown values into an array of strings or OptionWithValue objects. + * Handles cases where the input might not be an array by returning an empty array. + * @param options - The options array to normalize + * @returns An array of normalized options + */ +export function normalizeOptions(options: unknown): Array { + if (!Array.isArray(options)) { + return []; + } + return options.map(normalizeOption); +} + +/** + * Sanitizes an option to ensure it has proper primitive values for label/value. + * This is the main utility for processing complex nested option structures that can + * come from various sources (APIs, form data, etc.) into a format compatible with react-select. + * + * Handles multiple nesting scenarios: + * 1. String options: returned as-is (e.g., "simple-option") + * 2. __lv wrapper objects: extracts inner option from {__lv: {label: "...", value: "..."}} + * 3. Nested label/value objects: handles {label: {label: "Documents"}, value: {value: "123"}} + * + * This function was created to fix React error #31 where nested objects were being + * passed to React components that expected primitive values. + * + * @param option - The option to sanitize (can be string, object, or complex nested structure) + * @returns A clean option with primitive label/value or a string + * + * @example + * // Simple string + * sanitizeOption("hello") // returns "hello" + * + * @example + * // Nested object structure + * sanitizeOption({ + * label: {label: "Documents", value: "123"}, + * value: {label: "Documents", value: "123"} + * }) // returns {label: "Documents", value: "123"} + * + * @example + * // __lv wrapper + * sanitizeOption({ + * __lv: {label: "Test", value: "test-id"} + * }) // returns {label: "Test", value: "test-id"} + */ +export function sanitizeOption(option: unknown): { label: string; value: unknown } | string { + if (typeof option === "string") return option; + + if (!option || typeof option !== "object") { + return { + label: "", + value: "", + }; + } + + // If option has __lv wrapper, extract the inner option + if ("__lv" in option) { + const innerOption = (option as Record).__lv; + + let actualLabel = ""; + let actualValue = innerOption?.value; + + // Handle nested label in __lv + if (innerOption?.label && typeof innerOption.label === "object" && "label" in innerOption.label) { + actualLabel = String(innerOption.label.label || ""); + } else { + actualLabel = String(innerOption?.label || innerOption?.value || ""); + } + + // Handle nested value in __lv + if (innerOption?.value && typeof innerOption.value === "object" && "value" in innerOption.value) { + actualValue = innerOption.value.value; + } + + return { + label: actualLabel, + value: actualValue, + }; + } + + // Handle nested label and value objects + const optionObj = option as Record; + let actualLabel = ""; + let actualValue = optionObj.value; + + // Extract nested label + if (optionObj.label && typeof optionObj.label === "object" && "label" in optionObj.label) { + actualLabel = String(optionObj.label.label || ""); + } else { + actualLabel = String(optionObj.label || optionObj.value || ""); + } + + // Extract nested value + if (optionObj.value && typeof optionObj.value === "object" && "value" in optionObj.value) { + actualValue = optionObj.value.value; + } + + return { + label: actualLabel, + value: actualValue, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c609f188238ef..7eab94dca7f0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16002,6 +16002,14 @@ importers: specifier: ^6.0.0 version: 6.2.0 + modelcontextprotocol/node_modules2/@modelcontextprotocol/sdk/dist/cjs: {} + + modelcontextprotocol/node_modules2/@modelcontextprotocol/sdk/dist/esm: {} + + modelcontextprotocol/node_modules2/zod-to-json-schema/dist/cjs: {} + + modelcontextprotocol/node_modules2/zod-to-json-schema/dist/esm: {} + packages/ai: dependencies: '@pipedream/sdk': @@ -29980,22 +29988,22 @@ packages: superagent@3.8.1: resolution: {integrity: sha512-VMBFLYgFuRdfeNQSMLbxGSLfmXL/xc+OO+BZp41Za/NRDBet/BNbkRJrYzCUu0u4GU0i/ml2dtT8b9qgkw9z6Q==} engines: {node: '>= 4.0'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net superagent@4.1.0: resolution: {integrity: sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag==} engines: {node: '>= 6.0'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net superagent@5.3.1: resolution: {integrity: sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==} engines: {node: '>= 7.0.0'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net superagent@7.1.6: resolution: {integrity: sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supports-color@2.0.0: resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==}