diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index c4ef3d82a7701..3387377bdba10 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -2,6 +2,14 @@ # Changelog +## [2.1.1] - 2025-10-27 + +### Fixed + +- Fixed optional props being removed when loading saved configurations +- Optional props with values now automatically display as enabled +- Improved handling of label-value format for remote options in multi-select fields + ## [2.1.0] - 2025-10-10 ### Added diff --git a/packages/connect-react/package.json b/packages/connect-react/package.json index 7d0810927d045..9b1ab702dfc7b 100644 --- a/packages/connect-react/package.json +++ b/packages/connect-react/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/connect-react", - "version": "2.1.0", + "version": "2.1.1", "description": "Pipedream Connect library for React", "files": [ "dist" diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 927c3829f5ce8..03221d11f9b0d 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -16,11 +16,18 @@ import CreatableSelect from "react-select/creatable"; import type { BaseReactSelectProps } from "../hooks/customization-context"; import { useCustomize } from "../hooks/customization-context"; import { useFormFieldContext } from "../hooks/form-field-context"; -import { LabelValueOption } from "../types"; +import type { + LabelValueOption, + RawPropOption, +} from "../types"; import { isOptionWithLabel, sanitizeOption, } from "../utils/type-guards"; +import { + isArrayOfLabelValueWrapped, + isLabelValueWrapped, +} from "../utils/label-value"; import { LoadMoreButton } from "./LoadMoreButton"; // XXX T and ConfigurableProp should be related @@ -85,15 +92,25 @@ export function ControlSelect({ return null; } + // Handle __lv-wrapped values (single object or array) returned from remote options + if (isLabelValueWrapped(rawValue)) { + const lvContent = rawValue.__lv; + if (Array.isArray(lvContent)) { + return lvContent.map((item) => sanitizeOption(item as RawPropOption)); + } + return sanitizeOption(lvContent as RawPropOption); + } + + if (isArrayOfLabelValueWrapped(rawValue)) { + return rawValue.map((item) => sanitizeOption(item as RawPropOption)); + } + if (Array.isArray(rawValue)) { // if simple, make lv (XXX combine this with other place this happens) if (!isOptionWithLabel(rawValue[0])) { return rawValue.map((o) => selectOptions.find((item) => item.value === o) || sanitizeOption(o as T)); } - } else if (rawValue && typeof rawValue === "object" && "__lv" in (rawValue as Record)) { - // Extract the actual option from __lv wrapper and sanitize to LV - return sanitizeOption(((rawValue as Record).__lv) as T); } else if (!isOptionWithLabel(rawValue)) { const lvOptions = selectOptions?.[0] && isOptionWithLabel(selectOptions[0]); if (lvOptions) { diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 586f6ae3981bf..39acafdf2a37f 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -36,6 +36,7 @@ import { } from "../types"; import { resolveUserId } from "../utils/resolve-user-id"; import { isConfigurablePropOfType } from "../utils/type-guards"; +import { hasLabelValueFormat } from "../utils/label-value"; export type AnyFormFieldContext = Omit, "onChange"> & { onChange: (value: unknown) => void; @@ -169,6 +170,7 @@ export const FormContextProvider = ({ }, [ component.key, ]); + // XXX pass this down? (in case we make it hash or set backed, but then also provide {add,remove} instead of set) const optionalPropIsEnabled = (prop: ConfigurableProp) => enabledOptionalProps[prop.name]; @@ -354,13 +356,35 @@ export const FormContextProvider = ({ setErrors(_errors); }; + const preserveIntegerValue = (prop: ConfigurableProp, value: unknown) => { + if (prop.type !== "integer" || typeof value === "number") { + return value; + } + return hasLabelValueFormat(value) + ? value + : undefined; + }; + useEffect(() => { - // Initialize queryDisabledIdx on load so that we don't force users - // to reconfigure a prop they've already configured whenever the page - // or component is reloaded - updateConfiguredPropsQueryDisabledIdx(_configuredProps) + // Initialize queryDisabledIdx using actual configuredProps (includes parent-passed values in controlled mode) + // instead of _configuredProps which starts empty. This ensures that when mounting with pre-configured + // values, remote options queries are not incorrectly blocked. + updateConfiguredPropsQueryDisabledIdx(configuredProps) }, [ - _configuredProps, + component.key, + configurableProps, + enabledOptionalProps, + ]); + + // Update queryDisabledIdx reactively when configuredProps changes. + // This prevents race conditions where queryDisabledIdx updates synchronously before + // configuredProps completes its state update, causing duplicate API calls with stale data. + useEffect(() => { + updateConfiguredPropsQueryDisabledIdx(configuredProps); + }, [ + configuredProps, + configurableProps, + enabledOptionalProps, ]); useEffect(() => { @@ -386,8 +410,13 @@ export const FormContextProvider = ({ if (skippablePropTypes.includes(prop.type)) { continue; } - // if prop.optional and not shown, we skip and do on un-collapse + // if prop.optional and not shown, we still preserve the value if it exists + // This prevents losing saved values for optional props that haven't been enabled yet if (prop.optional && !optionalPropIsEnabled(prop)) { + const value = configuredProps[prop.name as keyof ConfiguredProps]; + if (value !== undefined) { + newConfiguredProps[prop.name as keyof ConfiguredProps] = value; + } continue; } const value = configuredProps[prop.name as keyof ConfiguredProps]; @@ -397,10 +426,14 @@ export const FormContextProvider = ({ newConfiguredProps[prop.name as keyof ConfiguredProps] = prop.default as any; // eslint-disable-line @typescript-eslint/no-explicit-any } } else { - if (prop.type === "integer" && typeof value !== "number") { + // Preserve label-value format from remote options dropdowns for integer props. + // Remote options store values as {__lv: {label: "...", value: ...}} (or arrays of __lv objects). + // For integer props we drop anything that isn't number or label-value formatted to avoid corrupt data. + const preservedValue = preserveIntegerValue(prop, value); + if (preservedValue === undefined) { delete newConfiguredProps[prop.name as keyof ConfiguredProps]; } else { - newConfiguredProps[prop.name as keyof ConfiguredProps] = value; + newConfiguredProps[prop.name as keyof ConfiguredProps] = preservedValue as any; // eslint-disable-line @typescript-eslint/no-explicit-any } } } @@ -409,6 +442,8 @@ export const FormContextProvider = ({ } }, [ configurableProps, + enabledOptionalProps, + configuredProps, ]); // clear all props on user change @@ -440,9 +475,6 @@ export const FormContextProvider = ({ if (prop.reloadProps) { setReloadPropIdx(idx); } - if (prop.type === "app" || prop.remoteOptions) { - updateConfiguredPropsQueryDisabledIdx(newConfiguredProps); - } const errs = propErrors(prop, value); const newErrors = { ...errors, @@ -478,6 +510,23 @@ export const FormContextProvider = ({ setEnabledOptionalProps(newEnabledOptionalProps); }; + // Auto-enable optional props with saved values so dependent dynamic props reload correctly + useEffect(() => { + for (const prop of configurableProps) { + if (!prop.optional) continue; + if (enabledOptionalProps[prop.name]) continue; + const value = configuredProps[prop.name as keyof ConfiguredProps]; + if (value === undefined) continue; + optionalPropSetEnabled(prop, true); + } + }, [ + component.key, + configurableProps, + configuredProps, + enabledOptionalProps, + optionalPropSetEnabled, + ]); + const checkPropsNeedConfiguring = () => { const _propsNeedConfiguring = [] for (const prop of configurableProps) { diff --git a/packages/connect-react/src/utils/label-value.ts b/packages/connect-react/src/utils/label-value.ts new file mode 100644 index 0000000000000..0bf62410e40a8 --- /dev/null +++ b/packages/connect-react/src/utils/label-value.ts @@ -0,0 +1,50 @@ +import type { PropOptionValue } from "@pipedream/sdk"; +import type { RawPropOption } from "../types"; + +/** + * Utilities for detecting and handling label-value (__lv) format + * used by Pipedream components to preserve display labels for option values. + */ + +/** + * Shape returned by remote options when values include their original label. + * The wrapped payload may itself be a single option or an array of options. + */ +export type LabelValueWrapped = Extract, { __lv: unknown }>; + +/** + * Runtime type guard for the label-value wrapper. + * @param value - The value to check + * @returns true if value is an object with a non-null __lv payload + */ +export function isLabelValueWrapped( + value: unknown, +): value is LabelValueWrapped { + if (!value || typeof value !== "object") return false; + if (!("__lv" in value)) return false; + + const lvContent = (value as LabelValueWrapped).__lv; + return lvContent != null; +} + +/** + * Checks if every entry in an array is a label-value wrapper. + * @param value - The value to check + * @returns true if all entries are wrapped and contain non-null payloads + */ +export function isArrayOfLabelValueWrapped( + value: unknown, +): value is Array> { + if (!Array.isArray(value) || value.length === 0) return false; + + return value.every((item) => isLabelValueWrapped(item)); +} + +/** + * Checks if a value has the label-value format (either single or array) + * @param value - The value to check + * @returns true if value is in __lv format (single or array) + */ +export function hasLabelValueFormat(value: unknown): boolean { + return isLabelValueWrapped(value) || isArrayOfLabelValueWrapped(value); +}