Skip to content

Commit d9b91ad

Browse files
Fixes GH-17399 (#17747)
* Fixes GH-17399 * Update pnpm-lock.yaml * Update pnpm-lock.yaml * Update pnpm-lock.yaml * Linting * Merge branch 'danny/connect-react/gh-17399' of github.com:PipedreamHQ/pipedream into danny/connect-react/gh-17399 * Linting and documenting the utility functions
1 parent ae0072d commit d9b91ad

File tree

5 files changed

+252
-33
lines changed

5 files changed

+252
-33
lines changed

packages/connect-react/src/components/ControlSelect.tsx

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { useFormFieldContext } from "../hooks/form-field-context";
1111
import { useCustomize } from "../hooks/customization-context";
1212
import type { BaseReactSelectProps } from "../hooks/customization-context";
1313
import { LoadMoreButton } from "./LoadMoreButton";
14+
import {
15+
isOptionWithValue, OptionWithValue, sanitizeOption,
16+
} from "../utils/type-guards";
1417

1518
// XXX T and ConfigurableProp should be related
1619
type ControlSelectProps<T> = {
@@ -41,10 +44,11 @@ export function ControlSelect<T>({
4144
] = useState(value);
4245

4346
useEffect(() => {
44-
setSelectOptions(options)
47+
const sanitizedOptions = options.map(sanitizeOption);
48+
setSelectOptions(sanitizedOptions);
4549
}, [
4650
options,
47-
])
51+
]);
4852

4953
useEffect(() => {
5054
setRawValue(value)
@@ -67,11 +71,11 @@ export function ControlSelect<T>({
6771
if (ret != null) {
6872
if (Array.isArray(ret)) {
6973
// if simple, make lv (XXX combine this with other place this happens)
70-
if (typeof ret[0] !== "object") {
74+
if (!isOptionWithValue(ret[0])) {
7175
const lvs = [];
7276
for (const o of ret) {
7377
let obj = {
74-
label: o,
78+
label: String(o),
7579
value: o,
7680
}
7781
for (const item of selectOptions) {
@@ -84,8 +88,11 @@ export function ControlSelect<T>({
8488
}
8589
ret = lvs;
8690
}
87-
} else if (typeof ret !== "object") {
88-
const lvOptions = selectOptions?.[0] && typeof selectOptions[0] === "object";
91+
} else if (ret && typeof ret === "object" && "__lv" in ret) {
92+
// Extract the actual option from __lv wrapper
93+
ret = ret.__lv;
94+
} else if (!isOptionWithValue(ret)) {
95+
const lvOptions = selectOptions?.[0] && isOptionWithValue(selectOptions[0]);
8996
if (lvOptions) {
9097
for (const item of selectOptions) {
9198
if (item.value === rawValue) {
@@ -95,12 +102,10 @@ export function ControlSelect<T>({
95102
}
96103
} else {
97104
ret = {
98-
label: rawValue,
105+
label: String(rawValue),
99106
value: rawValue,
100107
}
101108
}
102-
} else if (ret.__lv) {
103-
ret = ret.__lv
104109
}
105110
}
106111
return ret;
@@ -117,13 +122,14 @@ export function ControlSelect<T>({
117122
<components.MenuList {...props}>
118123
{ children }
119124
<div className="pt-4">
120-
<LoadMoreButton onChange={onLoadMore}/>
125+
<LoadMoreButton onChange={onLoadMore || (() => {})}/>
121126
</div>
122127
</components.MenuList>
123128
)
124129
}
125130

126131
const props = select.getProps("controlSelect", baseSelectProps)
132+
127133
if (showLoadMoreButton) {
128134
props.components = {
129135
// eslint-disable-next-line react/prop-types
@@ -133,24 +139,26 @@ export function ControlSelect<T>({
133139
}
134140

135141
const handleCreate = (inputValue: string) => {
136-
const createOption = (input: unknown) => {
137-
if (typeof input === "object") return input
142+
const createOption = (input: unknown): OptionWithValue => {
143+
if (isOptionWithValue(input)) return input
144+
const strValue = String(input);
138145
return {
139-
label: input,
140-
value: input,
146+
label: strValue,
147+
value: strValue,
141148
}
142149
}
143150
const newOption = createOption(inputValue)
144151
let newRawValue = newOption
145-
const newSelectOptions = selectOptions
146-
? [
147-
newOption,
148-
...selectOptions,
149-
]
150-
: [
151-
newOption,
152-
]
152+
153+
// NEVER add wrapped objects to selectOptions - only clean {label, value} objects
154+
const cleanSelectOptions = selectOptions.map(sanitizeOption);
155+
156+
const newSelectOptions = [
157+
newOption,
158+
...cleanSelectOptions,
159+
];
153160
setSelectOptions(newSelectOptions);
161+
154162
if (prop.type.endsWith("[]")) {
155163
if (Array.isArray(rawValue)) {
156164
newRawValue = [
@@ -170,14 +178,14 @@ export function ControlSelect<T>({
170178
const handleChange = (o: unknown) => {
171179
if (o) {
172180
if (Array.isArray(o)) {
173-
if (typeof o[0] === "object" && "value" in o[0]) {
181+
if (typeof o[0] === "object" && o[0] && "value" in o[0]) {
174182
onChange({
175183
__lv: o,
176184
});
177185
} else {
178186
onChange(o);
179187
}
180-
} else if (typeof o === "object" && "value" in o) {
188+
} else if (typeof o === "object" && o && "value" in o) {
181189
onChange({
182190
__lv: o,
183191
});
@@ -198,19 +206,33 @@ export function ControlSelect<T>({
198206
const MaybeCreatableSelect = isCreatable
199207
? CreatableSelect
200208
: Select;
209+
210+
// Final safety check - ensure NO __lv wrapped objects reach react-select
211+
const cleanedOptions = selectOptions.map(sanitizeOption);
212+
201213
return (
202214
<MaybeCreatableSelect
203215
inputId={id}
204216
instanceId={id}
205-
options={selectOptions}
217+
options={cleanedOptions}
206218
value={selectValue}
207219
isMulti={prop.type.endsWith("[]")}
208220
isClearable={true}
209221
required={!prop.optional}
222+
getOptionLabel={(option) => {
223+
return typeof option === "string"
224+
? option
225+
: String(option?.label || option?.value || "");
226+
}}
227+
getOptionValue={(option) => {
228+
return typeof option === "string"
229+
? option
230+
: String(option?.value || "");
231+
}}
232+
onChange={handleChange}
210233
{...props}
211234
{...selectProps}
212235
{...additionalProps}
213-
onChange={handleChange}
214236
/>
215237
);
216238
}

packages/connect-react/src/components/RemoteOptionsContainer.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useFormContext } from "../hooks/form-context";
55
import { useFormFieldContext } from "../hooks/form-field-context";
66
import { useFrontendClient } from "../hooks/frontend-client-context";
77
import { ControlSelect } from "./ControlSelect";
8+
import { isString } from "../utils/type-guards";
89

910
export type RemoteOptionsContainerProps = {
1011
queryEnabled?: boolean;
@@ -138,9 +139,16 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP
138139
const newOptions = []
139140
const allValues = new Set(pageable.values)
140141
for (const o of _options || []) {
141-
const value = typeof o === "string"
142-
? o
143-
: o.value
142+
let value: string | number;
143+
if (isString(o)) {
144+
value = o;
145+
} else if (o && typeof o === "object" && "value" in o && o.value != null) {
146+
value = o.value;
147+
} else {
148+
// Skip items that don't match expected format
149+
console.warn("Skipping invalid option:", o);
150+
continue;
151+
}
144152
if (allValues.has(value)) {
145153
continue
146154
}

packages/connect-react/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ export * from "./hooks/use-app";
3131
export * from "./hooks/use-apps";
3232
export * from "./hooks/use-component";
3333
export * from "./hooks/use-components";
34+
35+
// Debug info for development - consumers can choose to log this if needed
36+
export const DEBUG_INFO = {
37+
buildTime: new Date().toISOString(),
38+
source: "local-development",
39+
};
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Represents an option object with a value and optional label.
3+
* Used by react-select and similar components.
4+
*/
5+
export interface OptionWithValue {
6+
/** The actual value of the option (string or number) */
7+
value: string | number;
8+
/** Optional display label for the option */
9+
label?: string;
10+
/** Internal wrapper object (used by form handling logic) */
11+
__lv?: unknown;
12+
}
13+
14+
/**
15+
* Type guard to check if a value is a string.
16+
* @param value - The value to check
17+
* @returns true if the value is a string
18+
*/
19+
export function isString(value: unknown): value is string {
20+
return typeof value === "string";
21+
}
22+
23+
/**
24+
* Type guard to check if a value is a valid OptionWithValue object.
25+
* Validates that the object has a 'value' property that is either a string or number.
26+
* @param value - The value to check
27+
* @returns true if the value is a valid OptionWithValue
28+
*/
29+
export function isOptionWithValue(value: unknown): value is OptionWithValue {
30+
return (
31+
value !== null &&
32+
typeof value === "object" &&
33+
!Array.isArray(value) &&
34+
"value" in value &&
35+
(typeof (value as Record<string, unknown>).value === "string" || typeof (value as Record<string, unknown>).value === "number")
36+
);
37+
}
38+
39+
/**
40+
* Type guard to check if a value is an array of strings.
41+
* @param value - The value to check
42+
* @returns true if the value is a string array
43+
*/
44+
export function isStringArray(value: unknown): value is string[] {
45+
return Array.isArray(value) && value.every((item) => typeof item === "string");
46+
}
47+
48+
/**
49+
* Type guard to check if a value is an array of OptionWithValue objects.
50+
* @param value - The value to check
51+
* @returns true if the value is an array of valid OptionWithValue objects
52+
*/
53+
export function isOptionArray(value: unknown): value is OptionWithValue[] {
54+
return Array.isArray(value) && value.every((item) => isOptionWithValue(item));
55+
}
56+
57+
/**
58+
* Normalizes an unknown value into either a string or OptionWithValue.
59+
* Used for basic option processing where the input format is uncertain.
60+
* @param option - The option to normalize
61+
* @returns A normalized string or OptionWithValue object
62+
*/
63+
export function normalizeOption(option: unknown): OptionWithValue | string {
64+
if (isString(option)) {
65+
return option;
66+
}
67+
if (isOptionWithValue(option)) {
68+
return option;
69+
}
70+
return String(option);
71+
}
72+
73+
/**
74+
* Normalizes an array of unknown values into an array of strings or OptionWithValue objects.
75+
* Handles cases where the input might not be an array by returning an empty array.
76+
* @param options - The options array to normalize
77+
* @returns An array of normalized options
78+
*/
79+
export function normalizeOptions(options: unknown): Array<OptionWithValue | string> {
80+
if (!Array.isArray(options)) {
81+
return [];
82+
}
83+
return options.map(normalizeOption);
84+
}
85+
86+
/**
87+
* Sanitizes an option to ensure it has proper primitive values for label/value.
88+
* This is the main utility for processing complex nested option structures that can
89+
* come from various sources (APIs, form data, etc.) into a format compatible with react-select.
90+
*
91+
* Handles multiple nesting scenarios:
92+
* 1. String options: returned as-is (e.g., "simple-option")
93+
* 2. __lv wrapper objects: extracts inner option from {__lv: {label: "...", value: "..."}}
94+
* 3. Nested label/value objects: handles {label: {label: "Documents"}, value: {value: "123"}}
95+
*
96+
* This function was created to fix React error #31 where nested objects were being
97+
* passed to React components that expected primitive values.
98+
*
99+
* @param option - The option to sanitize (can be string, object, or complex nested structure)
100+
* @returns A clean option with primitive label/value or a string
101+
*
102+
* @example
103+
* // Simple string
104+
* sanitizeOption("hello") // returns "hello"
105+
*
106+
* @example
107+
* // Nested object structure
108+
* sanitizeOption({
109+
* label: {label: "Documents", value: "123"},
110+
* value: {label: "Documents", value: "123"}
111+
* }) // returns {label: "Documents", value: "123"}
112+
*
113+
* @example
114+
* // __lv wrapper
115+
* sanitizeOption({
116+
* __lv: {label: "Test", value: "test-id"}
117+
* }) // returns {label: "Test", value: "test-id"}
118+
*/
119+
export function sanitizeOption(option: unknown): { label: string; value: unknown } | string {
120+
if (typeof option === "string") return option;
121+
122+
if (!option || typeof option !== "object") {
123+
return {
124+
label: "",
125+
value: "",
126+
};
127+
}
128+
129+
// If option has __lv wrapper, extract the inner option
130+
if ("__lv" in option) {
131+
const innerOption = (option as Record<string, unknown>).__lv;
132+
133+
let actualLabel = "";
134+
let actualValue = innerOption?.value;
135+
136+
// Handle nested label in __lv
137+
if (innerOption?.label && typeof innerOption.label === "object" && "label" in innerOption.label) {
138+
actualLabel = String(innerOption.label.label || "");
139+
} else {
140+
actualLabel = String(innerOption?.label || innerOption?.value || "");
141+
}
142+
143+
// Handle nested value in __lv
144+
if (innerOption?.value && typeof innerOption.value === "object" && "value" in innerOption.value) {
145+
actualValue = innerOption.value.value;
146+
}
147+
148+
return {
149+
label: actualLabel,
150+
value: actualValue,
151+
};
152+
}
153+
154+
// Handle nested label and value objects
155+
const optionObj = option as Record<string, unknown>;
156+
let actualLabel = "";
157+
let actualValue = optionObj.value;
158+
159+
// Extract nested label
160+
if (optionObj.label && typeof optionObj.label === "object" && "label" in optionObj.label) {
161+
actualLabel = String(optionObj.label.label || "");
162+
} else {
163+
actualLabel = String(optionObj.label || optionObj.value || "");
164+
}
165+
166+
// Extract nested value
167+
if (optionObj.value && typeof optionObj.value === "object" && "value" in optionObj.value) {
168+
actualValue = optionObj.value.value;
169+
}
170+
171+
return {
172+
label: actualLabel,
173+
value: actualValue,
174+
};
175+
}

0 commit comments

Comments
 (0)