Browse Source

fix(dashboard): Fix struct custom fields not rendering options or custom components

The StructFormInput component was not properly rendering dropdowns for struct
fields with options, nor custom components specified via ui.component.

Root cause: SelectWithOptions used isStringFieldWithOptions type guard which
checks for 'readonly' property - a property that only exists on top-level
custom fields, not struct fields. This caused the component to return null.

Changes:
- Updated SelectWithOptions to handle both ConfigurableFieldDef and StructField
- Added isStringStructFieldWithOptions check alongside isStringFieldWithOptions
- Added extractFieldOptions utility to normalize options extraction
- Updated isStringStructFieldWithOptions to also check ui.options
- Added ui.component support for struct fields via getInputComponent lookup

Closes #4083

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Michael Bromley 1 day ago
parent
commit
4346cb9110

+ 23 - 7
packages/dashboard/src/lib/components/data-input/select-with-options.tsx

@@ -1,18 +1,27 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
 import {
+    ConfigurableFieldDef,
     DashboardFormComponent,
     DashboardFormComponentProps,
-    StringCustomFieldConfig,
+    StringStructField,
+    StructField,
 } from '@/vdb/framework/form-engine/form-engine-types.js';
-import { isReadonlyField, isStringFieldWithOptions } from '@/vdb/framework/form-engine/utils.js';
+import {
+    extractFieldOptions,
+    isReadonlyField,
+    isStringFieldWithOptions,
+    isStringStructFieldWithOptions,
+} from '@/vdb/framework/form-engine/utils.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { Trans } from '@lingui/react/macro';
 import React from 'react';
 import { MultiSelect } from '../shared/multi-select.js';
 
-export interface SelectWithOptionsProps extends DashboardFormComponentProps {
+export interface SelectWithOptionsProps extends Omit<DashboardFormComponentProps, 'fieldDef'> {
     placeholder?: React.ReactNode;
     isListField?: boolean;
+    /** Field definition - can be a regular custom field or a struct field with options */
+    fieldDef?: ConfigurableFieldDef | StructField;
 }
 
 /**
@@ -30,7 +39,9 @@ export function SelectWithOptions({
     isListField = false,
     disabled,
 }: Readonly<SelectWithOptionsProps>) {
-    const readOnly = disabled || isReadonlyField(fieldDef);
+    // Note: struct fields don't have 'readonly', so isReadonlyField will return false for them
+    // which is correct since struct fields are controlled by the parent struct's readonly state
+    const readOnly = disabled || isReadonlyField(fieldDef as ConfigurableFieldDef);
     const {
         settings: { displayLanguage },
     } = useUserSettings();
@@ -40,11 +51,16 @@ export function SelectWithOptions({
         const translation = label.find(t => t.languageCode === displayLanguage);
         return translation?.value ?? label[0]?.value ?? '';
     };
-    if (!fieldDef || !isStringFieldWithOptions(fieldDef)) {
+
+    // Support both regular custom fields and struct fields with options
+    const isCustomField = fieldDef && isStringFieldWithOptions(fieldDef as ConfigurableFieldDef);
+    const isStructField = fieldDef && isStringStructFieldWithOptions(fieldDef as StringStructField);
+
+    if (!fieldDef || (!isCustomField && !isStructField)) {
         return null;
     }
-    const options: NonNullable<StringCustomFieldConfig['options']> =
-        fieldDef.options ?? fieldDef.ui.options ?? [];
+
+    const options = extractFieldOptions(fieldDef);
 
     // Convert options to MultiSelect format
     const multiSelectItems = options.map(option => ({

+ 53 - 21
packages/dashboard/src/lib/components/data-input/struct-form-input.tsx

@@ -9,6 +9,7 @@ import {
 } from '@/vdb/components/ui/form.js';
 import { Input } from '@/vdb/components/ui/input.js';
 import { Switch } from '@/vdb/components/ui/switch.js';
+import { getInputComponent } from '@/vdb/framework/extension-api/input-component-extensions.js';
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { CheckIcon, PencilIcon, X } from 'lucide-react';
 import React, { useMemo, useState } from 'react';
@@ -20,7 +21,11 @@ import {
     StructCustomFieldConfig,
     StructField,
 } from '@/vdb/framework/form-engine/form-engine-types.js';
-import { isReadonlyField, isStructFieldConfig } from '@/vdb/framework/form-engine/utils.js';
+import {
+    isReadonlyField,
+    isStringStructFieldWithOptions,
+    isStructFieldConfig,
+} from '@/vdb/framework/form-engine/utils.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { CustomFieldListInput } from './custom-field-list-input.js';
 import { DateTimeInput } from './datetime-input.js';
@@ -138,7 +143,11 @@ export function StructFormInput({ fieldDef, ...field }: Readonly<DashboardFormCo
                                         </div>
                                         <div className="flex-[2]">
                                             <FormControl>
-                                                {renderStructFieldInput(structField, structInputField)}
+                                                {renderStructFieldInput(
+                                                    structField,
+                                                    structInputField,
+                                                    isReadonly,
+                                                )}
                                             </FormControl>
                                             <FormMessage />
                                         </div>
@@ -204,12 +213,28 @@ const renderStructFieldInput = (
 
     // Helper function to render single input for a struct field
     const renderSingleStructInput = (singleField: ControllerRenderProps<any, any>) => {
+        // Check for custom component via ui.component first
+        const customComponentId = structField.ui?.component as string | undefined;
+        if (customComponentId) {
+            const CustomComponent = getInputComponent(customComponentId);
+            if (CustomComponent) {
+                // Cast to any since struct fields share the relevant properties with ConfigurableFieldDef
+                // (name, type, ui, etc.) but aren't the same union type
+                return <CustomComponent {...singleField} fieldDef={structField as any} disabled={isReadonly} />;
+            }
+        }
+
         switch (structField.type) {
             case 'string': {
-                // Check if the field has options (dropdown)
-                const stringField = structField as any; // GraphQL union types need casting
-                if (stringField.options && stringField.options.length > 0) {
-                    return <SelectWithOptions {...singleField} fieldDef={stringField} isListField={false} />;
+                if (isStringStructFieldWithOptions(structField)) {
+                    return (
+                        <SelectWithOptions
+                            {...singleField}
+                            fieldDef={structField}
+                            isListField={false}
+                            disabled={isReadonly}
+                        />
+                    );
                 }
                 return (
                     <Input
@@ -269,21 +294,31 @@ const renderStructFieldInput = (
         }
     };
 
-    // Handle string fields with options (dropdown) - already handles list case with multi-select
-    if (structField.type === 'string') {
-        const stringField = structField as any; // GraphQL union types need casting
-        if (stringField.options && stringField.options.length > 0) {
-            return (
-                <SelectWithOptions
-                    {...inputField}
-                    fieldDef={stringField}
-                    disabled={isReadonly}
-                    isListField={isList}
-                />
-            );
+    // Check for custom component via ui.component first (for list fields)
+    const customComponentId = structField.ui?.component as string | undefined;
+    if (customComponentId) {
+        const CustomComponent = getInputComponent(customComponentId);
+        if (CustomComponent) {
+            // If the custom component handles lists itself, use it directly
+            if (CustomComponent.metadata?.isListInput === true || CustomComponent.metadata?.isListInput === 'dynamic') {
+                // Cast to any since struct fields share the relevant properties with ConfigurableFieldDef
+                return <CustomComponent {...inputField} fieldDef={structField as any} disabled={isReadonly} />;
+            }
         }
     }
 
+    // Handle string fields with options (dropdown) - already handles list case with multi-select
+    if (isStringStructFieldWithOptions(structField)) {
+        return (
+            <SelectWithOptions
+                {...inputField}
+                fieldDef={structField}
+                disabled={isReadonly}
+                isListField={isList}
+            />
+        );
+    }
+
     // For list struct fields, wrap with list input
     if (isList) {
         const getDefaultValue = () => {
@@ -302,9 +337,6 @@ const renderStructFieldInput = (
             }
         };
 
-        // Determine if the field type needs full width
-        const needsFullWidth = structField.type === 'text' || structField.type === 'localeText';
-
         return (
             <CustomFieldListInput
                 {...inputField}

+ 33 - 4
packages/dashboard/src/lib/framework/form-engine/utils.ts

@@ -253,14 +253,43 @@ export function isStringStructField(input: StructField): input is StringStructFi
 }
 
 /**
- * String struct field that has options (select dropdown)
+ * String struct field that has options (select dropdown).
+ * Checks for options defined either directly or via ui.options.
  */
 export function isStringStructFieldWithOptions(
     input: StructField,
 ): input is StringStructField & { options: any[] } {
-    return (
-        input.type === 'string' && input.hasOwnProperty('options') && Array.isArray((input as any).options)
-    );
+    if (input.type !== 'string') {
+        return false;
+    }
+    // Check for direct options property
+    if (input.hasOwnProperty('options') && Array.isArray((input as any).options)) {
+        return true;
+    }
+    // Also check for ui.options (fallback pattern)
+    if (Array.isArray((input as any).ui?.options)) {
+        return true;
+    }
+    return false;
+}
+
+/**
+ * Extracts options from a field definition, normalizing the different locations
+ * where options can be defined (direct property or ui.options).
+ * Works for both ConfigurableFieldDef and StructField types.
+ */
+export function extractFieldOptions(
+    field: ConfigurableFieldDef | StructField,
+): NonNullable<StringCustomFieldConfig['options']> {
+    // Check direct options property first
+    if ((field as any).options && Array.isArray((field as any).options)) {
+        return (field as any).options;
+    }
+    // Fall back to ui.options
+    if (field.ui?.options && Array.isArray(field.ui.options)) {
+        return field.ui.options;
+    }
+    return [];
 }
 
 /**