Selaa lähdekoodia

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

Michael Bromley 19 tuntia sitten
vanhempi
sitoutus
27ba7693a8

+ 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 [];
 }
 
 /**