diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 3d7c0a3499853..dc3d41bddca1e 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -92,7 +92,12 @@ export function ControlSelect({ } } 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); + // Handle both single objects and arrays wrapped in __lv + const lvContent = (rawValue as Record).__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) { diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index f3016cdebafe9..e471ff4a609e4 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -2,7 +2,9 @@ import type { ConfigurePropOpts, PropOptionValue, } from "@pipedream/sdk"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { + useEffect, useState, +} from "react"; import { useFormContext } from "../hooks/form-context"; import { useFormFieldContext } from "../hooks/form-field-context"; import { useFrontendClient } from "../hooks/frontend-client-context"; @@ -95,6 +97,19 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP setError, ] = useState<{ name: string; message: string; }>(); + // Reset pagination and error when dependent fields change. + // This ensures the next query starts fresh from page 0, triggering a data replace instead of append + useEffect(() => { + setPage(0); + setCanLoadMore(true); + setError(undefined); + }, [ + externalUserId, + component.key, + prop.name, + JSON.stringify(configuredPropsUpTo), + ]); + const onLoadMore = () => { setPage(pageable.page) setContext(pageable.prevContext) @@ -106,6 +121,7 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP // TODO handle error! const { + data, isFetching, refetch, } = useQuery({ queryKey: [ @@ -131,7 +147,13 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP message: errors[0], }); } - return []; + // Return proper pageable structure on error to prevent crashes + return { + page: 0, + prevContext: {}, + data: [], + values: new Set(), + }; } let _options: RawPropOption[] = [] if (options?.length) { @@ -148,8 +170,12 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP _options = options; } + // For fresh queries (page 0), start with empty set to avoid accumulating old options + // For pagination (page > 0), use existing set to dedupe across pages + const allValues = page === 0 + ? new Set() + : new Set(pageable.values) const newOptions = [] - const allValues = new Set(pageable.values) for (const o of _options || []) { let value: PropOptionValue; if (isString(o)) { @@ -167,26 +193,42 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP allValues.add(value) newOptions.push(o) } - let data = pageable.data + let responseData = pageable.data if (newOptions.length) { - data = [ - ...pageable.data, - ...newOptions, - ] as RawPropOption[] - setPageable({ + // Replace data on fresh queries (page 0), append on pagination (page > 0) + responseData = page === 0 + ? newOptions as RawPropOption[] + : [ + ...pageable.data, + ...newOptions, + ] as RawPropOption[] + const newPageable = { page: page + 1, prevContext: res.context, - data, + data: responseData, values: allValues, - }) + } + setPageable(newPageable) + return newPageable; } else { setCanLoadMore(false) + return pageable; } - return data; }, enabled: !!queryEnabled, }); + // Sync pageable state with query data to handle both fresh fetches and cached returns + // When React Query returns cached data, the queryFn doesn't run, so we need to sync + // the state here to ensure options populate correctly on remount + useEffect(() => { + if (data) { + setPageable(data); + } + }, [ + data, + ]); + const showLoadMoreButton = () => { return !isFetching && !error && canLoadMore } diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 586f6ae3981bf..e7d422d61c113 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -169,6 +169,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]; @@ -275,6 +276,32 @@ export const FormContextProvider = ({ 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 = {}; + + for (const prop of configurableProps) { + if (prop.optional) { + const value = configuredProps[prop.name as keyof ConfiguredProps]; + 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[] => { @@ -355,12 +382,21 @@ export const FormContextProvider = ({ }; 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, + ]); + + // 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, ]); useEffect(() => { @@ -386,8 +422,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]; @@ -398,7 +439,18 @@ export const FormContextProvider = ({ } } else { if (prop.type === "integer" && typeof value !== "number") { - delete newConfiguredProps[prop.name as keyof ConfiguredProps]; + // 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 + const isLabelValue = value && typeof value === "object" && "__lv" in value; + const isArrayOfLabelValues = Array.isArray(value) && value.length > 0 && + value.every((item) => item && typeof item === "object" && "__lv" in item); + + if (!(isLabelValue || isArrayOfLabelValues)) { + delete newConfiguredProps[prop.name as keyof ConfiguredProps]; + } else { + newConfiguredProps[prop.name as keyof ConfiguredProps] = value; + } } else { newConfiguredProps[prop.name as keyof ConfiguredProps] = value; } @@ -440,9 +492,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,