Skip to content
Merged
74 changes: 48 additions & 26 deletions packages/connect-react/src/components/ControlSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { useFormFieldContext } from "../hooks/form-field-context";
import { useCustomize } from "../hooks/customization-context";
import type { BaseReactSelectProps } from "../hooks/customization-context";
import { LoadMoreButton } from "./LoadMoreButton";
import {
isOptionWithValue, OptionWithValue, sanitizeOption,
} from "../utils/type-guards";

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

useEffect(() => {
setSelectOptions(options)
const sanitizedOptions = options.map(sanitizeOption);
setSelectOptions(sanitizedOptions);
}, [
options,
])
]);

useEffect(() => {
setRawValue(value)
Expand All @@ -67,11 +71,11 @@ export function ControlSelect<T>({
if (ret != null) {
if (Array.isArray(ret)) {
// if simple, make lv (XXX combine this with other place this happens)
if (typeof ret[0] !== "object") {
if (!isOptionWithValue(ret[0])) {
const lvs = [];
for (const o of ret) {
let obj = {
label: o,
label: String(o),
value: o,
}
for (const item of selectOptions) {
Expand All @@ -84,8 +88,11 @@ export function ControlSelect<T>({
}
ret = lvs;
}
} else if (typeof ret !== "object") {
const lvOptions = selectOptions?.[0] && typeof selectOptions[0] === "object";
} else if (ret && typeof ret === "object" && "__lv" in ret) {
// Extract the actual option from __lv wrapper
ret = ret.__lv;
} else if (!isOptionWithValue(ret)) {
const lvOptions = selectOptions?.[0] && isOptionWithValue(selectOptions[0]);
if (lvOptions) {
for (const item of selectOptions) {
if (item.value === rawValue) {
Expand All @@ -95,12 +102,10 @@ export function ControlSelect<T>({
}
} else {
ret = {
label: rawValue,
label: String(rawValue),
value: rawValue,
}
}
} else if (ret.__lv) {
ret = ret.__lv
}
}
return ret;
Expand All @@ -117,13 +122,14 @@ export function ControlSelect<T>({
<components.MenuList {...props}>
{ children }
<div className="pt-4">
<LoadMoreButton onChange={onLoadMore}/>
<LoadMoreButton onChange={onLoadMore || (() => {})}/>
</div>
</components.MenuList>
)
}

const props = select.getProps("controlSelect", baseSelectProps)

if (showLoadMoreButton) {
props.components = {
// eslint-disable-next-line react/prop-types
Expand All @@ -133,24 +139,26 @@ export function ControlSelect<T>({
}

const handleCreate = (inputValue: string) => {
const createOption = (input: unknown) => {
if (typeof input === "object") return input
const createOption = (input: unknown): OptionWithValue => {
if (isOptionWithValue(input)) return input
const strValue = String(input);
return {
label: input,
value: input,
label: strValue,
value: strValue,
}
}
const newOption = createOption(inputValue)
let newRawValue = newOption
const newSelectOptions = selectOptions
? [
newOption,
...selectOptions,
]
: [
newOption,
]

// NEVER add wrapped objects to selectOptions - only clean {label, value} objects
const cleanSelectOptions = selectOptions.map(sanitizeOption);

const newSelectOptions = [
newOption,
...cleanSelectOptions,
];
setSelectOptions(newSelectOptions);

if (prop.type.endsWith("[]")) {
if (Array.isArray(rawValue)) {
newRawValue = [
Expand All @@ -170,14 +178,14 @@ export function ControlSelect<T>({
const handleChange = (o: unknown) => {
if (o) {
if (Array.isArray(o)) {
if (typeof o[0] === "object" && "value" in o[0]) {
if (typeof o[0] === "object" && o[0] && "value" in o[0]) {
onChange({
__lv: o,
});
} else {
onChange(o);
}
} else if (typeof o === "object" && "value" in o) {
} else if (typeof o === "object" && o && "value" in o) {
onChange({
__lv: o,
});
Expand All @@ -198,19 +206,33 @@ export function ControlSelect<T>({
const MaybeCreatableSelect = isCreatable
? CreatableSelect
: Select;

// Final safety check - ensure NO __lv wrapped objects reach react-select
const cleanedOptions = selectOptions.map(sanitizeOption);

return (
<MaybeCreatableSelect
inputId={id}
instanceId={id}
options={selectOptions}
options={cleanedOptions}
value={selectValue}
isMulti={prop.type.endsWith("[]")}
isClearable={true}
required={!prop.optional}
getOptionLabel={(option) => {
return typeof option === "string"
? option
: String(option?.label || option?.value || "");
}}
getOptionValue={(option) => {
return typeof option === "string"
? option
: String(option?.value || "");
}}
onChange={handleChange}
{...props}
{...selectProps}
{...additionalProps}
onChange={handleChange}
/>
);
}
14 changes: 11 additions & 3 deletions packages/connect-react/src/components/RemoteOptionsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useFormContext } from "../hooks/form-context";
import { useFormFieldContext } from "../hooks/form-field-context";
import { useFrontendClient } from "../hooks/frontend-client-context";
import { ControlSelect } from "./ControlSelect";
import { isString } from "../utils/type-guards";

export type RemoteOptionsContainerProps = {
queryEnabled?: boolean;
Expand Down Expand Up @@ -138,9 +139,16 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP
const newOptions = []
const allValues = new Set(pageable.values)
for (const o of _options || []) {
const value = typeof o === "string"
? o
: o.value
let value: string | number;
if (isString(o)) {
value = o;
} else if (o && typeof o === "object" && "value" in o && o.value != null) {
value = o.value;
} else {
// Skip items that don't match expected format
console.warn("Skipping invalid option:", o);
continue;
}
if (allValues.has(value)) {
continue
}
Expand Down
6 changes: 6 additions & 0 deletions packages/connect-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ export * from "./hooks/use-app";
export * from "./hooks/use-apps";
export * from "./hooks/use-component";
export * from "./hooks/use-components";

// Debug info for development - consumers can choose to log this if needed
export const DEBUG_INFO = {
buildTime: new Date().toISOString(),
source: "local-development",
};
175 changes: 175 additions & 0 deletions packages/connect-react/src/utils/type-guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Represents an option object with a value and optional label.
* Used by react-select and similar components.
*/
export interface OptionWithValue {
/** The actual value of the option (string or number) */
value: string | number;
/** Optional display label for the option */
label?: string;
/** Internal wrapper object (used by form handling logic) */
__lv?: unknown;
}

/**
* Type guard to check if a value is a string.
* @param value - The value to check
* @returns true if the value is a string
*/
export function isString(value: unknown): value is string {
return typeof value === "string";
}

/**
* Type guard to check if a value is a valid OptionWithValue object.
* Validates that the object has a 'value' property that is either a string or number.
* @param value - The value to check
* @returns true if the value is a valid OptionWithValue
*/
export function isOptionWithValue(value: unknown): value is OptionWithValue {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
"value" in value &&
(typeof (value as Record<string, unknown>).value === "string" || typeof (value as Record<string, unknown>).value === "number")
);
}

/**
* Type guard to check if a value is an array of strings.
* @param value - The value to check
* @returns true if the value is a string array
*/
export function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}

/**
* Type guard to check if a value is an array of OptionWithValue objects.
* @param value - The value to check
* @returns true if the value is an array of valid OptionWithValue objects
*/
export function isOptionArray(value: unknown): value is OptionWithValue[] {
return Array.isArray(value) && value.every((item) => isOptionWithValue(item));
}

/**
* Normalizes an unknown value into either a string or OptionWithValue.
* Used for basic option processing where the input format is uncertain.
* @param option - The option to normalize
* @returns A normalized string or OptionWithValue object
*/
export function normalizeOption(option: unknown): OptionWithValue | string {
if (isString(option)) {
return option;
}
if (isOptionWithValue(option)) {
return option;
}
return String(option);
}

/**
* Normalizes an array of unknown values into an array of strings or OptionWithValue objects.
* Handles cases where the input might not be an array by returning an empty array.
* @param options - The options array to normalize
* @returns An array of normalized options
*/
export function normalizeOptions(options: unknown): Array<OptionWithValue | string> {
if (!Array.isArray(options)) {
return [];
}
return options.map(normalizeOption);
}

/**
* Sanitizes an option to ensure it has proper primitive values for label/value.
* This is the main utility for processing complex nested option structures that can
* come from various sources (APIs, form data, etc.) into a format compatible with react-select.
*
* Handles multiple nesting scenarios:
* 1. String options: returned as-is (e.g., "simple-option")
* 2. __lv wrapper objects: extracts inner option from {__lv: {label: "...", value: "..."}}
* 3. Nested label/value objects: handles {label: {label: "Documents"}, value: {value: "123"}}
*
* This function was created to fix React error #31 where nested objects were being
* passed to React components that expected primitive values.
*
* @param option - The option to sanitize (can be string, object, or complex nested structure)
* @returns A clean option with primitive label/value or a string
*
* @example
* // Simple string
* sanitizeOption("hello") // returns "hello"
*
* @example
* // Nested object structure
* sanitizeOption({
* label: {label: "Documents", value: "123"},
* value: {label: "Documents", value: "123"}
* }) // returns {label: "Documents", value: "123"}
*
* @example
* // __lv wrapper
* sanitizeOption({
* __lv: {label: "Test", value: "test-id"}
* }) // returns {label: "Test", value: "test-id"}
*/
export function sanitizeOption(option: unknown): { label: string; value: unknown } | string {
if (typeof option === "string") return option;

if (!option || typeof option !== "object") {
return {
label: "",
value: "",
};
}

// If option has __lv wrapper, extract the inner option
if ("__lv" in option) {
const innerOption = (option as Record<string, unknown>).__lv;

let actualLabel = "";
let actualValue = innerOption?.value;

// Handle nested label in __lv
if (innerOption?.label && typeof innerOption.label === "object" && "label" in innerOption.label) {
actualLabel = String(innerOption.label.label || "");
} else {
actualLabel = String(innerOption?.label || innerOption?.value || "");
}

// Handle nested value in __lv
if (innerOption?.value && typeof innerOption.value === "object" && "value" in innerOption.value) {
actualValue = innerOption.value.value;
}

return {
label: actualLabel,
value: actualValue,
};
}

// Handle nested label and value objects
const optionObj = option as Record<string, unknown>;
let actualLabel = "";
let actualValue = optionObj.value;

// Extract nested label
if (optionObj.label && typeof optionObj.label === "object" && "label" in optionObj.label) {
actualLabel = String(optionObj.label.label || "");
} else {
actualLabel = String(optionObj.label || optionObj.value || "");
}

// Extract nested value
if (optionObj.value && typeof optionObj.value === "object" && "value" in optionObj.value) {
actualValue = optionObj.value.value;
}

return {
label: actualLabel,
value: actualValue,
};
}
Loading
Loading