Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/connect-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/connect-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pipedream/connect-react",
"version": "2.1.0",
"version": "2.1.1",
"description": "Pipedream Connect library for React",
"files": [
"dist"
Expand Down
25 changes: 21 additions & 4 deletions packages/connect-react/src/components/ControlSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,15 +92,25 @@ export function ControlSelect<T extends PropOptionValue>({
return null;
}

// Handle __lv-wrapped values (single object or array) returned from remote options
if (isLabelValueWrapped<T>(rawValue)) {
const lvContent = rawValue.__lv;
if (Array.isArray(lvContent)) {
return lvContent.map((item) => sanitizeOption<T>(item as RawPropOption<T>));
}
return sanitizeOption<T>(lvContent as RawPropOption<T>);
}

if (isArrayOfLabelValueWrapped<T>(rawValue)) {
return rawValue.map((item) => sanitizeOption<T>(item as RawPropOption<T>));
}

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<string, unknown>)) {
// Extract the actual option from __lv wrapper and sanitize to LV
return sanitizeOption(((rawValue as Record<string, unknown>).__lv) as T);
} else if (!isOptionWithLabel(rawValue)) {
const lvOptions = selectOptions?.[0] && isOptionWithLabel(selectOptions[0]);
if (lvOptions) {
Expand Down
71 changes: 60 additions & 11 deletions packages/connect-react/src/hooks/form-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormFieldContext<ConfigurableProp>, "onChange"> & {
onChange: (value: unknown) => void;
Expand Down Expand Up @@ -169,6 +170,7 @@ export const FormContextProvider = <T extends ConfigurableProps>({
}, [
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];

Expand Down Expand Up @@ -354,13 +356,35 @@ export const FormContextProvider = <T extends ConfigurableProps>({
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(() => {
Expand All @@ -386,8 +410,13 @@ export const FormContextProvider = <T extends ConfigurableProps>({
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<T>];
if (value !== undefined) {
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = value;
}
continue;
}
const value = configuredProps[prop.name as keyof ConfiguredProps<T>];
Expand All @@ -397,10 +426,14 @@ export const FormContextProvider = <T extends ConfigurableProps>({
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = 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<T>];
} else {
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = value;
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = preservedValue as any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
}
}
Expand All @@ -409,6 +442,8 @@ export const FormContextProvider = <T extends ConfigurableProps>({
}
}, [
configurableProps,
enabledOptionalProps,
configuredProps,
]);

// clear all props on user change
Expand Down Expand Up @@ -440,9 +475,6 @@ export const FormContextProvider = <T extends ConfigurableProps>({
if (prop.reloadProps) {
setReloadPropIdx(idx);
}
if (prop.type === "app" || prop.remoteOptions) {
updateConfiguredPropsQueryDisabledIdx(newConfiguredProps);
}
const errs = propErrors(prop, value);
const newErrors = {
...errors,
Expand Down Expand Up @@ -478,6 +510,23 @@ export const FormContextProvider = <T extends ConfigurableProps>({
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<T>];
if (value === undefined) continue;
optionalPropSetEnabled(prop, true);
}
}, [
component.key,
configurableProps,
configuredProps,
enabledOptionalProps,
optionalPropSetEnabled,
]);

const checkPropsNeedConfiguring = () => {
const _propsNeedConfiguring = []
for (const prop of configurableProps) {
Expand Down
50 changes: 50 additions & 0 deletions packages/connect-react/src/utils/label-value.ts
Original file line number Diff line number Diff line change
@@ -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<T extends PropOptionValue = PropOptionValue> = Extract<RawPropOption<T>, { __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<T extends PropOptionValue = PropOptionValue>(
value: unknown,
): value is LabelValueWrapped<T> {
if (!value || typeof value !== "object") return false;
if (!("__lv" in value)) return false;

const lvContent = (value as LabelValueWrapped<T>).__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<T extends PropOptionValue = PropOptionValue>(
value: unknown,
): value is Array<LabelValueWrapped<T>> {
if (!Array.isArray(value) || value.length === 0) return false;

return value.every((item) => isLabelValueWrapped<T>(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);
}
Loading