Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
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));
},
},
};
};
10 changes: 8 additions & 2 deletions packages/connect-react/src/components/ControlSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isOptionWithLabel,
sanitizeOption,
} from "../utils/type-guards";
import { isLabelValueWrapped } from "../utils/label-value";
import { LoadMoreButton } from "./LoadMoreButton";

// XXX T and ConfigurableProp should be related
Expand Down Expand Up @@ -91,9 +92,14 @@ export function ControlSelect<T extends PropOptionValue>({
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>)) {
} else if (isLabelValueWrapped(rawValue)) {
// Extract the actual option from __lv wrapper and sanitize to LV
return sanitizeOption(((rawValue as Record<string, unknown>).__lv) as T);
// Handle both single objects and arrays wrapped in __lv
const lvContent = (rawValue as Record<string, unknown>).__lv;
if (Array.isArray(lvContent)) {
return lvContent.map((item) => sanitizeOption(item as T));
}
return sanitizeOption(lvContent as T);
} else if (!isOptionWithLabel(rawValue)) {
const lvOptions = selectOptions?.[0] && isOptionWithLabel(selectOptions[0]);
if (lvOptions) {
Expand Down
75 changes: 65 additions & 10 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 @@ -275,6 +277,32 @@ export const FormContextProvider = <T extends ConfigurableProps>({
reloadPropIdx,
]);

// Auto-enable optional props that have values in configuredProps
// This ensures optional fields with saved values are shown when mounting with pre-configured props
useEffect(() => {
const propsToEnable: Record<string, boolean> = {};

for (const prop of configurableProps) {
if (prop.optional && !enabledOptionalProps[prop.name]) {
const value = configuredProps[prop.name as keyof ConfiguredProps<T>];
if (value !== undefined) {
propsToEnable[prop.name] = true;
}
}
}

if (Object.keys(propsToEnable).length > 0) {
setEnabledOptionalProps((prev) => ({
...prev,
...propsToEnable,
}));
}
}, [
component.key,
configurableProps,
configuredProps,
]);

// these validations are necessary because they might override PropInput for number case for instance
// so can't rely on that base control form validation
const propErrors = (prop: ConfigurableProp, value: unknown): string[] => {
Expand Down Expand Up @@ -355,12 +383,25 @@ export const FormContextProvider = <T extends ConfigurableProps>({
};

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 +427,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 @@ -398,7 +444,17 @@ export const FormContextProvider = <T extends ConfigurableProps>({
}
} else {
if (prop.type === "integer" && typeof value !== "number") {
delete newConfiguredProps[prop.name as keyof ConfiguredProps<T>];
// Preserve label-value format from remote options dropdowns
// Remote options store values as {__lv: {label: "...", value: ...}}
// For multi-select fields, this will be an array of __lv objects
// IMPORTANT: Integer props with remote options (like IDs) can be stored in __lv format
// to preserve the display label. We only delete the value if it's NOT in __lv format
// AND not a number, which indicates invalid/corrupted data.
if (!hasLabelValueFormat(value)) {
delete newConfiguredProps[prop.name as keyof ConfiguredProps<T>];
} else {
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = value;
}
} else {
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = value;
}
Expand All @@ -409,6 +465,8 @@ export const FormContextProvider = <T extends ConfigurableProps>({
}
}, [
configurableProps,
enabledOptionalProps,
configuredProps,
]);

// clear all props on user change
Expand Down Expand Up @@ -440,9 +498,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
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