diff --git a/components/alienvault/alienvault.app.mjs b/components/alienvault/alienvault.app.mjs index 00675fff990c7..c33efed1694e9 100644 --- a/components/alienvault/alienvault.app.mjs +++ b/components/alienvault/alienvault.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/beyond_presence/beyond_presence.app.mjs b/components/beyond_presence/beyond_presence.app.mjs index 01acee33ab2ea..9f131c5edc367 100644 --- a/components/beyond_presence/beyond_presence.app.mjs +++ b/components/beyond_presence/beyond_presence.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/callhippo/callhippo.app.mjs b/components/callhippo/callhippo.app.mjs index 0aa63af21d718..cb5a704a88049 100644 --- a/components/callhippo/callhippo.app.mjs +++ b/components/callhippo/callhippo.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; 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..8f11a0851b318 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 { + 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,26 @@ export function ControlSelect({ return null; } + // Handle __lv-wrapped values (single object or array) returned from remote options + if (isLabelValueWrapped(rawValue)) { + const lvContent = (rawValue as Record).__lv; + if (Array.isArray(lvContent)) { + return lvContent.map((item) => sanitizeOption(item as unknown as RawPropOption)); + } + return sanitizeOption(lvContent as unknown as RawPropOption); + } + + if (isArrayOfLabelValueWrapped(rawValue)) { + return (rawValue as Array>).map((item) => + sanitizeOption(item as unknown 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..73ee1da0d4a71 --- /dev/null +++ b/packages/connect-react/src/utils/label-value.ts @@ -0,0 +1,57 @@ +/** + * Utilities for detecting and handling label-value (__lv) format + * used by Pipedream components to preserve display labels for option values + */ + +/** + * Checks if a value is wrapped in the __lv format + * @param value - The value to check + * @returns true if value is an object with __lv property containing valid data + * + * @example + * isLabelValueWrapped({ __lv: { label: "Option 1", value: 123 } }) // true + * isLabelValueWrapped({ __lv: null }) // false + * isLabelValueWrapped({ value: 123 }) // false + */ +export function isLabelValueWrapped(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if (!("__lv" in value)) return false; + + const lvContent = (value as Record).__lv; + return lvContent != null; +} + +/** + * Checks if a value is an array of __lv wrapped objects + * @param value - The value to check + * @returns true if value is an array of valid __lv wrapped objects + * + * @example + * isArrayOfLabelValueWrapped([{ __lv: { label: "A", value: 1 } }]) // true + * isArrayOfLabelValueWrapped([]) // false + * isArrayOfLabelValueWrapped([{ value: 1 }]) // false + */ +export function isArrayOfLabelValueWrapped(value: unknown): boolean { + if (!Array.isArray(value)) return false; + if (value.length === 0) return false; + + return value.every((item) => + item && + typeof item === "object" && + "__lv" in item && + (item as Record).__lv != null); +} + +/** + * 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) + * + * @example + * hasLabelValueFormat({ __lv: { label: "A", value: 1 } }) // true + * hasLabelValueFormat([{ __lv: { label: "A", value: 1 } }]) // true + * hasLabelValueFormat({ value: 1 }) // false + */ +export function hasLabelValueFormat(value: unknown): boolean { + return isLabelValueWrapped(value) || isArrayOfLabelValueWrapped(value); +}