Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion components/alienvault/alienvault.app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ export default {
console.log(Object.keys(this.$auth));
},
},
};
};
2 changes: 1 addition & 1 deletion components/beyond_presence/beyond_presence.app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ export default {
console.log(Object.keys(this.$auth));
},
},
};
};
2 changes: 1 addition & 1 deletion components/callhippo/callhippo.app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ export default {
console.log(Object.keys(this.$auth));
},
},
};
};
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
26 changes: 22 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 {
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,26 @@ export function ControlSelect<T extends PropOptionValue>({
return null;
}

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

if (isArrayOfLabelValueWrapped(rawValue)) {
return (rawValue as Array<Record<string, unknown>>).map((item) =>
sanitizeOption(item as unknown 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
57 changes: 57 additions & 0 deletions packages/connect-react/src/utils/label-value.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).__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<string, unknown>).__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);
}
Loading