Explorar o código

feat(dashboard): Full support for custom fields in detail forms (#3695)

Michael Bromley hai 5 meses
pai
achega
38d6f736bc
Modificáronse 19 ficheiros con 2427 adicións e 112 borrados
  1. 39 5
      packages/core/src/api/common/validate-custom-field-value.spec.ts
  2. 7 6
      packages/core/src/api/common/validate-custom-field-value.ts
  3. 297 0
      packages/dashboard/src/lib/components/data-input/custom-field-list-input.tsx
  4. 5 2
      packages/dashboard/src/lib/components/data-input/datetime-input.tsx
  5. 572 0
      packages/dashboard/src/lib/components/data-input/default-relation-input.tsx
  6. 1 0
      packages/dashboard/src/lib/components/data-input/index.ts
  7. 7 6
      packages/dashboard/src/lib/components/data-input/relation-selector.tsx
  8. 84 0
      packages/dashboard/src/lib/components/data-input/select-with-options.tsx
  9. 324 0
      packages/dashboard/src/lib/components/data-input/struct-form-input.tsx
  10. 207 36
      packages/dashboard/src/lib/components/shared/custom-fields-form.tsx
  11. 1 1
      packages/dashboard/src/lib/components/shared/multi-select.tsx
  12. 4 4
      packages/dashboard/src/lib/components/ui/form.tsx
  13. 472 0
      packages/dashboard/src/lib/framework/form-engine/form-schema-tools.spec.ts
  14. 340 5
      packages/dashboard/src/lib/framework/form-engine/form-schema-tools.ts
  15. 24 8
      packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx
  16. 3 9
      packages/dashboard/src/lib/framework/form-engine/utils.ts
  17. 11 3
      packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx
  18. 3 3
      packages/dashboard/src/lib/framework/page/use-detail-page.ts
  19. 26 24
      packages/dashboard/src/lib/lib/utils.ts

+ 39 - 5
packages/core/src/api/common/validate-custom-field-value.spec.ts

@@ -3,8 +3,8 @@ import { fail } from 'assert';
 import { describe, expect, it } from 'vitest';
 
 import { Injector } from '../../common/injector';
-import { RequestContext } from './request-context';
 
+import { RequestContext } from './request-context';
 import { validateCustomFieldValue } from './validate-custom-field-value';
 
 describe('validateCustomFieldValue()', () => {
@@ -22,7 +22,7 @@ describe('validateCustomFieldValue()', () => {
     const ctx = RequestContext.empty();
 
     describe('string & localeString', () => {
-        const validate = (value: string) => () =>
+        const validate = (value: string | null) => () =>
             validateCustomFieldValue(
                 {
                     name: 'test',
@@ -45,10 +45,14 @@ describe('validateCustomFieldValue()', () => {
             await assertThrowsError(validate('foo'), 'error.field-invalid-string-pattern');
             await assertThrowsError(validate(' 1foo'), 'error.field-invalid-string-pattern');
         });
+
+        it('allows null for nullable field with pattern', async () => {
+            expect(validate(null)).not.toThrow();
+        });
     });
 
     describe('string options', () => {
-        const validate = (value: string) => () =>
+        const validate = (value: string | null) => () =>
             validateCustomFieldValue(
                 {
                     name: 'test',
@@ -70,10 +74,32 @@ describe('validateCustomFieldValue()', () => {
             await assertThrowsError(validate(''), 'error.field-invalid-string-option');
             await assertThrowsError(validate('bad'), 'error.field-invalid-string-option');
         });
+
+        it('allows null for a nullable field', () => {
+            expect(validate(null)).not.toThrow();
+        });
+
+        it('throws on null for non-nullable field', async () => {
+            await assertThrowsError(
+                () =>
+                    validateCustomFieldValue(
+                        {
+                            name: 'test',
+                            type: 'string',
+                            nullable: false,
+                            options: [{ value: 'small' }, { value: 'large' }],
+                        },
+                        null,
+                        injector,
+                        ctx,
+                    ),
+                'error.field-invalid-non-nullable',
+            );
+        });
     });
 
     describe('int & float', () => {
-        const validate = (value: number) => () =>
+        const validate = (value: number | null) => () =>
             validateCustomFieldValue(
                 {
                     name: 'test',
@@ -97,10 +123,14 @@ describe('validateCustomFieldValue()', () => {
             await assertThrowsError(validate(11), 'error.field-invalid-number-range-max');
             await assertThrowsError(validate(-7), 'error.field-invalid-number-range-min');
         });
+
+        it('allows null for nullable field', async () => {
+            expect(validate(null)).not.toThrow();
+        });
     });
 
     describe('datetime', () => {
-        const validate = (value: string) => () =>
+        const validate = (value: string | null) => () =>
             validateCustomFieldValue(
                 {
                     name: 'test',
@@ -129,6 +159,10 @@ describe('validateCustomFieldValue()', () => {
                 'error.field-invalid-datetime-range-max',
             );
         });
+
+        it('allows null for nullable field', async () => {
+            expect(validate(null)).not.toThrow();
+        });
     });
 
     describe('validate function', () => {

+ 7 - 6
packages/core/src/api/common/validate-custom-field-value.ts

@@ -130,7 +130,7 @@ function validateStringField(
     value: string,
 ): void {
     const { pattern } = config;
-    if (pattern) {
+    if (pattern && value != null) {
         const re = new RegExp(pattern);
         if (!re.test(value)) {
             throw new UserInputError('error.field-invalid-string-pattern', {
@@ -143,7 +143,7 @@ function validateStringField(
     const options = (config as StringCustomFieldConfig).options;
     if (options) {
         const validOptions = options.map(o => o.value);
-        if (value === null && (config as StringCustomFieldConfig).nullable === true) {
+        if (value === null && (config as StringCustomFieldConfig).nullable !== false) {
             return;
         }
         if (!validOptions.includes(value)) {
@@ -158,24 +158,25 @@ function validateStringField(
 
 function validateNumberField(config: IntCustomFieldConfig | FloatCustomFieldConfig, value: number): void {
     const { min, max } = config;
-    if (min != null && value < min) {
+    if (min != null && value != null && value < min) {
         throw new UserInputError('error.field-invalid-number-range-min', { name: config.name, value, min });
     }
-    if (max != null && max < value) {
+    if (max != null && value != null && max < value) {
         throw new UserInputError('error.field-invalid-number-range-max', { name: config.name, value, max });
     }
 }
+
 function validateDateTimeField(config: DateTimeCustomFieldConfig, value: string): void {
     const { min, max } = config;
     const valueDate = new Date(value);
-    if (min != null && valueDate < new Date(min)) {
+    if (min != null && value != null && valueDate < new Date(min)) {
         throw new UserInputError('error.field-invalid-datetime-range-min', {
             name: config.name,
             value: valueDate.toISOString(),
             min,
         });
     }
-    if (max != null && new Date(max) < valueDate) {
+    if (max != null && value != null && new Date(max) < valueDate) {
         throw new UserInputError('error.field-invalid-datetime-range-max', {
             name: config.name,
             value: valueDate.toISOString(),

+ 297 - 0
packages/dashboard/src/lib/components/data-input/custom-field-list-input.tsx

@@ -0,0 +1,297 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { useLingui } from '@/vdb/lib/trans.js';
+import {
+    closestCenter,
+    DndContext,
+    KeyboardSensor,
+    PointerSensor,
+    useSensor,
+    useSensors,
+} from '@dnd-kit/core';
+import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
+import {
+    arrayMove,
+    SortableContext,
+    sortableKeyboardCoordinates,
+    useSortable,
+    verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { GripVertical, Plus, X } from 'lucide-react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { ControllerRenderProps } from 'react-hook-form';
+
+interface ListItemWithId {
+    _id: string;
+    value: any;
+}
+
+interface CustomFieldListInputProps {
+    field: ControllerRenderProps<any, any>;
+    disabled?: boolean;
+    renderInput: (index: number, inputField: ControllerRenderProps<any, any>) => React.ReactNode;
+    defaultValue?: any;
+}
+
+interface SortableItemProps {
+    itemWithId: ListItemWithId;
+    index: number;
+    disabled?: boolean;
+    renderInput: (index: number, inputField: ControllerRenderProps<any, any>) => React.ReactNode;
+    onRemove: (id: string) => void;
+    onItemChange: (id: string, value: any) => void;
+    field: ControllerRenderProps<any, any>;
+    isFullWidth?: boolean;
+}
+
+function SortableItem({
+    itemWithId,
+    index,
+    disabled,
+    renderInput,
+    onRemove,
+    onItemChange,
+    field,
+    isFullWidth = false,
+}: Readonly<SortableItemProps>) {
+    const { i18n } = useLingui();
+    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+        id: itemWithId._id,
+        disabled,
+    });
+
+    const style = {
+        transform: CSS.Transform.toString(transform),
+        transition,
+    };
+
+    const DragHandle = !disabled && (
+        <div
+            {...attributes}
+            {...listeners}
+            className="cursor-move text-muted-foreground hover:text-foreground transition-colors"
+            title={i18n.t('Drag to reorder')}
+        >
+            <GripVertical className="h-4 w-4" />
+        </div>
+    );
+
+    const RemoveButton = !disabled && (
+        <Button
+            type="button"
+            variant="ghost"
+            size="sm"
+            onClick={() => onRemove(itemWithId._id)}
+            className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive transition-colors opacity-0 group-hover:opacity-100"
+            title={i18n.t('Remove item')}
+        >
+            <X className="h-3 w-3" />
+        </Button>
+    );
+
+    if (!isFullWidth) {
+        // Inline layout for single-line inputs
+        return (
+            <div
+                ref={setNodeRef}
+                style={style}
+                className={`group relative flex items-center gap-2 p-2 border rounded-lg bg-card hover:bg-accent/50 transition-colors ${
+                    isDragging ? 'opacity-50 shadow-lg' : ''
+                }`}
+            >
+                {DragHandle}
+                <div className="flex-1">
+                    {renderInput(index, {
+                        name: `${field.name}.${index}`,
+                        value: itemWithId.value,
+                        onChange: value => onItemChange(itemWithId._id, value),
+                        onBlur: field.onBlur,
+                        ref: field.ref,
+                    } as ControllerRenderProps<any, any>)}
+                </div>
+                {RemoveButton}
+            </div>
+        );
+    }
+
+    // Full-width layout for complex inputs
+    return (
+        <div
+            ref={setNodeRef}
+            style={style}
+            className={`group relative border rounded-lg bg-card hover:bg-accent/50 transition-colors ${
+                isDragging ? 'opacity-50 shadow-lg' : ''
+            }`}
+        >
+            <div className="flex items-center justify-between px-3 py-2 border-b">
+                {DragHandle}
+                <div className="flex-1" />
+                {RemoveButton}
+            </div>
+            <div className="p-3">
+                {renderInput(index, {
+                    name: `${field.name}.${index}`,
+                    value: itemWithId.value,
+                    onChange: value => onItemChange(itemWithId._id, value),
+                    onBlur: field.onBlur,
+                    ref: field.ref,
+                } as ControllerRenderProps<any, any>)}
+            </div>
+        </div>
+    );
+}
+
+// Generate unique IDs for list items
+function generateId(): string {
+    return `item-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+}
+
+// Convert flat array to array with stable IDs
+function convertToItemsWithIds(values: any[], existingItems?: ListItemWithId[]): ListItemWithId[] {
+    if (!values || values.length === 0) return [];
+
+    return values.map((value, index) => {
+        // Try to reuse existing ID if the value matches and index is within bounds
+        const existingItem = existingItems?.[index];
+        if (existingItem && JSON.stringify(existingItem.value) === JSON.stringify(value)) {
+            return existingItem;
+        }
+
+        // Otherwise create new item with new ID
+        return {
+            _id: generateId(),
+            value,
+        };
+    });
+}
+
+// Convert array with IDs back to flat array
+function convertToFlatArray(itemsWithIds: ListItemWithId[]): any[] {
+    return itemsWithIds.map(item => item.value);
+}
+
+export function CustomFieldListInput({
+    field,
+    disabled,
+    renderInput,
+    defaultValue,
+    isFullWidth = false,
+}: CustomFieldListInputProps & { isFullWidth?: boolean }) {
+    const { i18n } = useLingui();
+    const sensors = useSensors(
+        useSensor(PointerSensor),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        }),
+    );
+
+    // Keep track of items with stable IDs
+    const [itemsWithIds, setItemsWithIds] = useState<ListItemWithId[]>(() =>
+        convertToItemsWithIds(field.value || []),
+    );
+
+    // Update items when field value changes externally (e.g., form reset, initial load)
+    useEffect(() => {
+        const newItems = convertToItemsWithIds(field.value || [], itemsWithIds);
+        if (
+            JSON.stringify(convertToFlatArray(newItems)) !== JSON.stringify(convertToFlatArray(itemsWithIds))
+        ) {
+            setItemsWithIds(newItems);
+        }
+    }, [field.value, itemsWithIds]);
+
+    const itemIds = useMemo(() => itemsWithIds.map(item => item._id), [itemsWithIds]);
+
+    const handleAddItem = useCallback(() => {
+        const newItem: ListItemWithId = {
+            _id: generateId(),
+            value: defaultValue ?? '',
+        };
+        const newItemsWithIds = [...itemsWithIds, newItem];
+        setItemsWithIds(newItemsWithIds);
+        field.onChange(convertToFlatArray(newItemsWithIds));
+    }, [itemsWithIds, defaultValue, field]);
+
+    const handleRemoveItem = useCallback(
+        (id: string) => {
+            const newItemsWithIds = itemsWithIds.filter(item => item._id !== id);
+            setItemsWithIds(newItemsWithIds);
+            field.onChange(convertToFlatArray(newItemsWithIds));
+        },
+        [itemsWithIds, field],
+    );
+
+    const handleItemChange = useCallback(
+        (id: string, value: any) => {
+            const newItemsWithIds = itemsWithIds.map(item => (item._id === id ? { ...item, value } : item));
+            setItemsWithIds(newItemsWithIds);
+            field.onChange(convertToFlatArray(newItemsWithIds));
+        },
+        [itemsWithIds, field],
+    );
+
+    const handleDragEnd = useCallback(
+        (event: any) => {
+            const { active, over } = event;
+
+            if (over && active.id !== over.id) {
+                const oldIndex = itemIds.indexOf(active.id);
+                const newIndex = itemIds.indexOf(over.id);
+
+                const newItemsWithIds = arrayMove(itemsWithIds, oldIndex, newIndex);
+                setItemsWithIds(newItemsWithIds);
+                field.onChange(convertToFlatArray(newItemsWithIds));
+            }
+        },
+        [itemIds, itemsWithIds, field],
+    );
+
+    const containerClasses = useMemo(() => {
+        const contentClasses =
+            'overflow-y-auto resize-y border-b rounded bg-muted/30 bg-background p-1 space-y-1';
+
+        if (itemsWithIds.length === 0) {
+            return `hidden`;
+        } else if (itemsWithIds.length > 5) {
+            return `h-[200px] ${contentClasses}`;
+        } else {
+            return `min-h-[100px] ${contentClasses}`;
+        }
+    }, [itemsWithIds.length]);
+
+    return (
+        <div className="space-y-2">
+            <DndContext
+                sensors={sensors}
+                collisionDetection={closestCenter}
+                onDragEnd={handleDragEnd}
+                modifiers={[restrictToVerticalAxis]}
+            >
+                <div className={containerClasses}>
+                    <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
+                        {itemsWithIds.map((itemWithId, index) => (
+                            <SortableItem
+                                key={itemWithId._id}
+                                itemWithId={itemWithId}
+                                index={index}
+                                disabled={disabled}
+                                renderInput={renderInput}
+                                onRemove={handleRemoveItem}
+                                onItemChange={handleItemChange}
+                                field={field}
+                                isFullWidth={isFullWidth}
+                            />
+                        ))}
+                    </SortableContext>
+                </div>
+            </DndContext>
+
+            {!disabled && (
+                <Button type="button" variant="outline" size="sm" onClick={handleAddItem} className="w-full">
+                    <Plus className="h-4 w-4 mr-2" />
+                    {i18n.t('Add item')}
+                </Button>
+            )}
+        </div>
+    );
+}

+ 5 - 2
packages/dashboard/src/lib/components/data-input/datetime-input.tsx

@@ -13,9 +13,11 @@ import { CalendarClock } from 'lucide-react';
 export interface DateTimeInputProps {
     value: Date | string | undefined;
     onChange: (value: Date) => void;
+    disabled?: boolean;
 }
 
 export function DateTimeInput(props: DateTimeInputProps) {
+    const { disabled = false } = props;
     const date = props.value && props.value instanceof Date ? props.value.toISOString() : (props.value ?? '');
     const [isOpen, setIsOpen] = React.useState(false);
 
@@ -42,12 +44,13 @@ export function DateTimeInput(props: DateTimeInputProps) {
     };
 
     return (
-        <Popover open={isOpen} onOpenChange={setIsOpen}>
+        <Popover open={isOpen} onOpenChange={disabled ? undefined : setIsOpen}>
             <PopoverTrigger asChild>
                 <Button
                     variant="outline"
+                    disabled={disabled}
                     className={cn(
-                        'w-full justify-start text-left font-normal',
+                        'w-full justify-start text-left font-normal shadow-xs',
                         !date && 'text-muted-foreground',
                     )}
                 >

+ 572 - 0
packages/dashboard/src/lib/components/data-input/default-relation-input.tsx

@@ -0,0 +1,572 @@
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { RelationCustomFieldConfig } from '@vendure/common/lib/generated-types';
+import { ControllerRenderProps } from 'react-hook-form';
+import { MultiRelationInput, SingleRelationInput } from './relation-input.js';
+import { createRelationSelectorConfig } from './relation-selector.js';
+
+interface PlaceholderIconProps {
+    letter: string;
+    className?: string;
+    rounded?: boolean;
+}
+
+function PlaceholderIcon({ letter, className = '', rounded = false }: Readonly<PlaceholderIconProps>) {
+    return (
+        <div
+            className={`w-full h-full bg-muted flex items-center justify-center border rounded text-muted-foreground ${rounded ? 'text-sm font-medium' : 'text-xs'} ${className}`}
+        >
+            {letter}
+        </div>
+    );
+}
+
+interface EntityLabelProps {
+    title: string;
+    subtitle: string;
+    imageUrl?: string;
+    placeholderLetter: string;
+    statusIndicator?: React.ReactNode;
+    rounded?: boolean;
+    tooltipText?: string;
+}
+
+interface StatusBadgeProps {
+    condition: boolean;
+    text: string;
+    variant?: 'orange' | 'green' | 'red' | 'blue';
+}
+
+function StatusBadge({ condition, text, variant = 'orange' }: Readonly<StatusBadgeProps>) {
+    if (!condition) return null;
+
+    const colorClasses = {
+        orange: 'text-orange-600',
+        green: 'bg-green-100 text-green-700',
+        red: 'bg-red-100 text-red-700',
+        blue: 'bg-blue-100 text-blue-700',
+    };
+
+    return (
+        <span
+            className={`ml-2 text-xs ${variant === 'orange' ? colorClasses.orange : `px-1.5 py-0.5 rounded-full ${colorClasses[variant]}`}`}
+        >
+            • {text}
+        </span>
+    );
+}
+
+function EntityLabel({
+    title,
+    subtitle,
+    imageUrl,
+    placeholderLetter,
+    statusIndicator,
+    rounded = false,
+    tooltipText,
+}: Readonly<EntityLabelProps>) {
+    return (
+        <div className="flex items-center gap-3 w-full" title={tooltipText || `${title} (${subtitle})`}>
+            <div
+                className={`w-8 h-8 ${rounded ? 'rounded-full' : 'rounded overflow-hidden'} bg-muted flex-shrink-0`}
+            >
+                {imageUrl ? (
+                    <img
+                        src={imageUrl + '?preset=thumb'}
+                        alt={title}
+                        className="w-full h-full object-cover"
+                    />
+                ) : (
+                    <PlaceholderIcon letter={placeholderLetter} rounded={rounded} />
+                )}
+            </div>
+            <div className="flex-1 min-w-0">
+                <div className="font-medium truncate">
+                    {title}
+                    {statusIndicator}
+                </div>
+                <div className="text-sm text-muted-foreground truncate">{subtitle}</div>
+            </div>
+        </div>
+    );
+}
+
+function createBaseEntityConfig(
+    entityName: string,
+    i18n: any,
+    labelKey: 'name' | 'code' | 'emailAddress' = 'name',
+    searchField: string = 'name',
+) {
+    return {
+        idKey: 'id',
+        labelKey,
+        placeholder: i18n.t(`Search ${entityName.toLowerCase()}s...`),
+        buildSearchFilter: (term: string) => ({
+            [searchField]: { contains: term },
+        }),
+    } as const;
+}
+
+function getOrderStateVariant(state: string): StatusBadgeProps['variant'] {
+    switch (state) {
+        case 'Delivered':
+            return 'green';
+        case 'Cancelled':
+            return 'red';
+        default:
+            return 'blue';
+    }
+}
+
+// Entity type mappings from the dev-config.ts - using functions to generate configs
+const createEntityConfigs = (i18n: any) => ({
+    Product: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Product', i18n),
+        listQuery: graphql(`
+            query GetProductsForRelationSelector($options: ProductListOptions) {
+                products(options: $options) {
+                    items {
+                        id
+                        name
+                        slug
+                        enabled
+                        featuredAsset {
+                            id
+                            preview
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.slug}${!item.enabled ? ' • Disabled' : ''}`}
+                imageUrl={item.featuredAsset?.preview}
+                placeholderLetter="P"
+                tooltipText={`${item.name} (${item.slug})`}
+            />
+        ),
+    }),
+
+    Customer: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Customer', i18n, 'emailAddress', 'emailAddress'),
+        listQuery: graphql(`
+            query GetCustomersForRelationSelector($options: CustomerListOptions) {
+                customers(options: $options) {
+                    items {
+                        id
+                        firstName
+                        lastName
+                        emailAddress
+                        phoneNumber
+                        user {
+                            verified
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={`${item.firstName} ${item.lastName}`}
+                subtitle={[item.emailAddress, item.phoneNumber].filter(Boolean).join(' • ')}
+                placeholderLetter={
+                    item.firstName?.[0]?.toUpperCase() || item.emailAddress?.[0]?.toUpperCase() || 'U'
+                }
+                rounded
+                statusIndicator={<StatusBadge condition={!item.user?.verified} text="Unverified" />}
+                tooltipText={`${item.firstName} ${item.lastName} (${item.emailAddress})`}
+            />
+        ),
+    }),
+
+    ProductVariant: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Product Variant', i18n),
+        listQuery: graphql(`
+            query GetProductVariantsForRelationSelector($options: ProductVariantListOptions) {
+                productVariants(options: $options) {
+                    items {
+                        id
+                        name
+                        sku
+                        enabled
+                        stockOnHand
+                        product {
+                            name
+                            featuredAsset {
+                                id
+                                preview
+                            }
+                        }
+                        featuredAsset {
+                            id
+                            preview
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={`${item.product.name} - ${item.name}`}
+                subtitle={`SKU: ${item.sku} • Stock: ${item.stockOnHand ?? 0}`}
+                imageUrl={item.featuredAsset?.preview || item.product.featuredAsset?.preview}
+                placeholderLetter="V"
+                statusIndicator={<StatusBadge condition={!item.enabled} text="Disabled" />}
+                tooltipText={`${item.product.name} - ${item.name} (SKU: ${item.sku})`}
+            />
+        ),
+    }),
+
+    Collection: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Collection', i18n),
+        listQuery: graphql(`
+            query GetCollectionsForRelationSelector($options: CollectionListOptions) {
+                collections(options: $options) {
+                    items {
+                        id
+                        name
+                        slug
+                        isPrivate
+                        position
+                        productVariants {
+                            totalItems
+                        }
+                        featuredAsset {
+                            id
+                            preview
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.slug} • ${item.productVariants?.totalItems || 0} products`}
+                imageUrl={item.featuredAsset?.preview}
+                placeholderLetter="C"
+                statusIndicator={<StatusBadge condition={item.isPrivate} text="Private" />}
+                tooltipText={`${item.name} (${item.slug})`}
+            />
+        ),
+    }),
+
+    Facet: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Facet', i18n),
+        listQuery: graphql(`
+            query GetFacetsForRelationSelector($options: FacetListOptions) {
+                facets(options: $options) {
+                    items {
+                        id
+                        name
+                        code
+                        isPrivate
+                        valueList {
+                            totalItems
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.code} • ${item.valueList?.totalItems || 0} values`}
+                placeholderLetter="F"
+                rounded
+                statusIndicator={<StatusBadge condition={item.isPrivate} text="Private" />}
+                tooltipText={`${item.name} (${item.code})`}
+            />
+        ),
+    }),
+
+    FacetValue: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Facet Value', i18n),
+        listQuery: graphql(`
+            query GetFacetValuesForRelationSelector($options: FacetValueListOptions) {
+                facetValues(options: $options) {
+                    items {
+                        id
+                        name
+                        code
+                        facet {
+                            name
+                            code
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.facet.name} • ${item.code}`}
+                placeholderLetter="FV"
+                rounded
+                tooltipText={`${item.facet.name}: ${item.name} (${item.code})`}
+            />
+        ),
+    }),
+
+    Asset: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Asset', i18n),
+        listQuery: graphql(`
+            query GetAssetsForRelationSelector($options: AssetListOptions) {
+                assets(options: $options) {
+                    items {
+                        id
+                        name
+                        preview
+                        source
+                        mimeType
+                        fileSize
+                        width
+                        height
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => {
+            const dimensions = item.width && item.height ? `${item.width}×${item.height}` : '';
+            const fileSize = item.fileSize ? `${Math.round(item.fileSize / 1024)}KB` : '';
+            const subtitle = [item.mimeType, dimensions, fileSize].filter(Boolean).join(' • ');
+            const tooltipDetails = [item.mimeType, dimensions, fileSize].filter(Boolean).join(', ');
+
+            return (
+                <EntityLabel
+                    title={item.name}
+                    subtitle={subtitle}
+                    imageUrl={item.preview}
+                    placeholderLetter="A"
+                    tooltipText={`${item.name} (${tooltipDetails})`}
+                />
+            );
+        },
+    }),
+
+    Order: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Order', i18n, 'code', 'code'),
+        listQuery: graphql(`
+            query GetOrdersForRelationSelector($options: OrderListOptions) {
+                orders(options: $options) {
+                    items {
+                        id
+                        code
+                        state
+                        totalWithTax
+                        currencyCode
+                        orderPlacedAt
+                        customer {
+                            firstName
+                            lastName
+                            emailAddress
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => {
+            const stateVariant = getOrderStateVariant(item.state);
+            return (
+                <EntityLabel
+                    title={item.code}
+                    subtitle={`${item.customer?.firstName} ${item.customer?.lastName} • ${item.totalWithTax / 100} ${item.currencyCode}`}
+                    placeholderLetter="O"
+                    rounded
+                    statusIndicator={
+                        <StatusBadge condition={true} text={item.state} variant={stateVariant} />
+                    }
+                    tooltipText={`${item.code} - ${item.customer?.firstName} ${item.customer?.lastName} (${item.totalWithTax / 100} ${item.currencyCode})`}
+                />
+            );
+        },
+    }),
+
+    // OrderLine: Not available as a list query in the admin API, fallback to basic input
+
+    ShippingMethod: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Shipping Method', i18n),
+        listQuery: graphql(`
+            query GetShippingMethodsForRelationSelector($options: ShippingMethodListOptions) {
+                shippingMethods(options: $options) {
+                    items {
+                        id
+                        name
+                        code
+                        description
+                        fulfillmentHandlerCode
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.code} • ${item.fulfillmentHandlerCode}`}
+                placeholderLetter="S"
+                rounded
+                tooltipText={`${item.name} (${item.code})`}
+            />
+        ),
+    }),
+
+    PaymentMethod: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Payment Method', i18n),
+        listQuery: graphql(`
+            query GetPaymentMethodsForRelationSelector($options: PaymentMethodListOptions) {
+                paymentMethods(options: $options) {
+                    items {
+                        id
+                        name
+                        code
+                        description
+                        enabled
+                        handler {
+                            code
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.code} • ${item.handler?.code}`}
+                placeholderLetter="P"
+                rounded
+                statusIndicator={<StatusBadge condition={!item.enabled} text="Disabled" />}
+                tooltipText={`${item.name} (${item.code})`}
+            />
+        ),
+    }),
+
+    Channel: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Channel', i18n, 'code', 'code'),
+        listQuery: graphql(`
+            query GetChannelsForRelationSelector($options: ChannelListOptions) {
+                channels(options: $options) {
+                    items {
+                        id
+                        code
+                        token
+                        defaultLanguageCode
+                        currencyCode
+                        pricesIncludeTax
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.code}
+                subtitle={`${item.defaultLanguageCode} • ${item.currencyCode} • ${item.pricesIncludeTax ? 'Inc. Tax' : 'Ex. Tax'}`}
+                placeholderLetter="CH"
+                rounded
+                tooltipText={`${item.code} (${item.defaultLanguageCode}, ${item.currencyCode})`}
+            />
+        ),
+    }),
+
+    Promotion: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Promotion', i18n),
+        listQuery: graphql(`
+            query GetPromotionsForRelationSelector($options: PromotionListOptions) {
+                promotions(options: $options) {
+                    items {
+                        id
+                        name
+                        couponCode
+                        enabled
+                        startsAt
+                        endsAt
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => {
+            const parts = [
+                item.couponCode,
+                item.startsAt && `Starts: ${new Date(item.startsAt).toLocaleDateString()}`,
+                item.endsAt && `Ends: ${new Date(item.endsAt).toLocaleDateString()}`,
+            ].filter(Boolean);
+
+            return (
+                <EntityLabel
+                    title={item.name}
+                    subtitle={parts.join(' • ')}
+                    placeholderLetter="PR"
+                    rounded
+                    statusIndicator={<StatusBadge condition={!item.enabled} text="Disabled" />}
+                    tooltipText={item.couponCode ? `${item.name} (${item.couponCode})` : item.name}
+                />
+            );
+        },
+    }),
+});
+
+interface DefaultRelationInputProps {
+    fieldDef: RelationCustomFieldConfig;
+    field: ControllerRenderProps<any, any>;
+    disabled?: boolean;
+}
+
+export function DefaultRelationInput({ fieldDef, field, disabled }: Readonly<DefaultRelationInputProps>) {
+    const { i18n } = useLingui();
+    const entityName = fieldDef.entity;
+    const ENTITY_CONFIGS = createEntityConfigs(i18n);
+    const config = ENTITY_CONFIGS[entityName as keyof typeof ENTITY_CONFIGS];
+
+    if (!config) {
+        // Fallback to plain input if entity type not found
+        console.warn(`No relation selector config found for entity: ${entityName}`);
+        return (
+            <input
+                value={field.value ?? ''}
+                onChange={e => field.onChange(e.target.value)}
+                onBlur={field.onBlur}
+                name={field.name}
+                disabled={disabled}
+                placeholder={`Enter ${entityName} ID`}
+                className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+            />
+        );
+    }
+
+    const isList = fieldDef.list ?? false;
+
+    if (isList) {
+        return (
+            <MultiRelationInput
+                value={field.value ?? []}
+                onChange={field.onChange}
+                config={config}
+                disabled={disabled}
+                selectorLabel={<Trans>Select {entityName.toLowerCase()}s</Trans>}
+            />
+        );
+    } else {
+        return (
+            <SingleRelationInput
+                value={field.value ?? ''}
+                onChange={field.onChange}
+                config={config}
+                disabled={disabled}
+                selectorLabel={<Trans>Select {entityName.toLowerCase()}</Trans>}
+            />
+        );
+    }
+}

+ 1 - 0
packages/dashboard/src/lib/components/data-input/index.ts

@@ -5,6 +5,7 @@ export * from './datetime-input.js';
 export * from './facet-value-input.js';
 export * from './money-input.js';
 export * from './rich-text-input.js';
+export * from './select-with-options.js';
 
 // Relation selector components
 export * from './relation-input.js';

+ 7 - 6
packages/dashboard/src/lib/components/data-input/relation-selector.tsx

@@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/pop
 import { getQueryName } from '@/vdb/framework/document-introspection/get-document-structure.js';
 import { api } from '@/vdb/graphql/api.js';
 import { Trans } from '@/vdb/lib/trans.js';
+import { cn } from '@/vdb/lib/utils.js';
 import { useInfiniteQuery } from '@tanstack/react-query';
 import { useDebounce } from '@uidotdev/usehooks';
 import type { DocumentNode } from 'graphql';
@@ -27,7 +28,7 @@ export interface RelationSelectorConfig<T = any> {
     /** Number of items to load per page */
     pageSize?: number;
     /** Placeholder text for the search input */
-    placeholder?: string;
+    placeholder?: React.ReactNode;
     /** Whether to enable multi-select mode */
     multiple?: boolean;
     /** Custom filter function for search */
@@ -366,7 +367,7 @@ export function RelationSelector<T>({
     }, [selectedItemsCache, selectedIds, config.idKey, isMultiple]);
 
     return (
-        <div className={className}>
+        <div className={cn('overflow-auto', className)}>
             {/* Display selected items */}
             {selectedItems.length > 0 && (
                 <div className="flex flex-wrap gap-2 mb-2">
@@ -376,9 +377,9 @@ export function RelationSelector<T>({
                         return (
                             <div
                                 key={itemId}
-                                className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
+                                className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm max-w-full min-w-0"
                             >
-                                <span>{label}</span>
+                                <div className="min-w-0 flex-1">{label}</div>
                                 {!disabled && (
                                     <button
                                         type="button"
@@ -403,10 +404,10 @@ export function RelationSelector<T>({
                             {isMultiple
                                 ? selectedItems.length > 0
                                     ? `Add more (${selectedItems.length} selected)`
-                                    : selectorLabel ?? <Trans>Select items</Trans>
+                                    : (selectorLabel ?? <Trans>Select items</Trans>)
                                 : selectedItems.length > 0
                                   ? 'Change selection'
-                                  : selectorLabel ?? <Trans>Select item</Trans>}
+                                  : (selectorLabel ?? <Trans>Select item</Trans>)}
                         </Trans>
                     </Button>
                 </PopoverTrigger>

+ 84 - 0
packages/dashboard/src/lib/components/data-input/select-with-options.tsx

@@ -0,0 +1,84 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
+import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { StringFieldOption } from '@vendure/common/lib/generated-types';
+import React from 'react';
+import { ControllerRenderProps } from 'react-hook-form';
+import { MultiSelect } from '../shared/multi-select.js';
+
+export interface SelectWithOptionsProps {
+    field: ControllerRenderProps<any, any>;
+    options: StringFieldOption[];
+    disabled?: boolean;
+    placeholder?: React.ReactNode;
+    isListField?: boolean;
+}
+
+/**
+ * @description
+ * A select component that renders options from custom field configuration.
+ * It automatically handles localization of option labels based on user settings.
+ *
+ * @since 3.3.0
+ */
+export function SelectWithOptions({
+    field,
+    options,
+    disabled,
+    placeholder,
+    isListField = false,
+}: Readonly<SelectWithOptionsProps>) {
+    const {
+        settings: { displayLanguage },
+    } = useUserSettings();
+
+    const getTranslation = (label: Array<{ languageCode: string; value: string }> | null) => {
+        if (!label) return '';
+        const translation = label.find(t => t.languageCode === displayLanguage);
+        return translation?.value ?? label[0]?.value ?? '';
+    };
+
+    // Convert options to MultiSelect format
+    const multiSelectItems = options.map(option => ({
+        value: option.value,
+        label: option.label ? getTranslation(option.label) : option.value,
+    }));
+
+    // For list fields, use MultiSelect component
+    if (isListField) {
+        return (
+            <MultiSelect
+                multiple={true}
+                value={field.value || []}
+                onChange={field.onChange}
+                items={multiSelectItems}
+                placeholder={placeholder ? String(placeholder) : 'Select options'}
+                className={disabled ? 'opacity-50 pointer-events-none' : ''}
+            />
+        );
+    }
+
+    // For single fields, use regular Select
+    const currentValue = field.value ?? '';
+
+    const handleValueChange = (value: string) => {
+        if (value) {
+            field.onChange(value);
+        }
+    };
+
+    return (
+        <Select value={currentValue ?? undefined} onValueChange={handleValueChange} disabled={disabled}>
+            <SelectTrigger>
+                <SelectValue placeholder={placeholder || <Trans>Select an option</Trans>} />
+            </SelectTrigger>
+            <SelectContent>
+                {options.map(option => (
+                    <SelectItem key={option.value} value={option.value}>
+                        {option.label ? getTranslation(option.label) : option.value}
+                    </SelectItem>
+                ))}
+            </SelectContent>
+        </Select>
+    );
+}

+ 324 - 0
packages/dashboard/src/lib/components/data-input/struct-form-input.tsx

@@ -0,0 +1,324 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from '@/vdb/components/ui/form.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Switch } from '@/vdb/components/ui/switch.js';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
+import { structCustomFieldFragment } from '@/vdb/providers/server-config.js';
+import { ResultOf } from 'gql.tada';
+import { CheckIcon, PencilIcon, X } from 'lucide-react';
+import React, { useMemo, useState } from 'react';
+import { Control, ControllerRenderProps, useWatch } from 'react-hook-form';
+
+// Import the form input component we already have
+import { CustomFieldListInput } from './custom-field-list-input.js';
+import { DateTimeInput } from './datetime-input.js';
+import { SelectWithOptions } from './select-with-options.js';
+
+// Use the generated types from GraphQL fragments
+type StructCustomFieldConfig = ResultOf<typeof structCustomFieldFragment>;
+type StructField = StructCustomFieldConfig['fields'][number];
+
+interface StructFormInputProps {
+    field: ControllerRenderProps<any, any>;
+    fieldDef: StructCustomFieldConfig;
+    control: Control<any, any>;
+    getTranslation: (
+        input: Array<{ languageCode: string; value: string }> | null | undefined,
+    ) => string | undefined;
+}
+
+interface DisplayModeProps {
+    fieldDef: StructCustomFieldConfig;
+    watchedStructValue: Record<string, any>;
+    isReadonly: boolean;
+    setIsEditing: (value: boolean) => void;
+    getTranslation: (
+        input: Array<{ languageCode: string; value: string }> | null | undefined,
+    ) => string | undefined;
+    formatFieldValue: (value: any, structField: StructField) => React.ReactNode;
+}
+
+function DisplayMode({
+    fieldDef,
+    watchedStructValue,
+    isReadonly,
+    setIsEditing,
+    getTranslation,
+    formatFieldValue,
+}: Readonly<DisplayModeProps>) {
+    return (
+        <div className="border rounded-md p-4">
+            <div className="flex justify-end">
+                {!isReadonly && (
+                    <Button
+                        variant="ghost"
+                        size="sm"
+                        onClick={() => setIsEditing(true)}
+                        className="h-8 w-8 p-0 -mt-2 -mr-2 text-muted-foreground hover:text-foreground"
+                    >
+                        <PencilIcon className="h-4 w-4" />
+                        <span className="sr-only">Edit</span>
+                    </Button>
+                )}
+            </div>
+            <dl className="grid grid-cols-2 divide-y divide-muted -mt-2">
+                {fieldDef.fields.map(structField => (
+                    <React.Fragment key={structField.name}>
+                        <dt className="text-sm font-medium text-muted-foreground py-2">
+                            {getTranslation(structField.label) ?? structField.name}
+                        </dt>
+                        <dd className="text-sm text-foreground py-2">
+                            {formatFieldValue(watchedStructValue[structField.name], structField)}
+                        </dd>
+                    </React.Fragment>
+                ))}
+            </dl>
+        </div>
+    );
+}
+
+export function StructFormInput({ field, fieldDef, control, getTranslation }: StructFormInputProps) {
+    const { formatDate } = useLocalFormat();
+    const isReadonly = fieldDef.readonly ?? false;
+    const [isEditing, setIsEditing] = useState(false);
+
+    // Watch the struct field for changes to update display mode
+    const watchedStructValue =
+        useWatch({
+            control,
+            name: field.name,
+            defaultValue: field.value || {},
+        }) || {};
+
+    // Helper function to format field value for display
+    const formatFieldValue = (value: any, structField: StructField) => {
+        if (value == null) return '-';
+        if (structField.list) {
+            if (Array.isArray(value)) {
+                return value.length ? value.join(', ') : '-';
+            }
+            return '-';
+        }
+        switch (structField.type) {
+            case 'boolean':
+                return (
+                    <span className={`inline-flex items-center ${value ? 'text-green-600' : 'text-red-500'}`}>
+                        {value ? <CheckIcon className="h-4 w-4" /> : <X className="h-4 w-4" />}
+                    </span>
+                );
+            case 'datetime':
+                return value ? formatDate(value, { dateStyle: 'short', timeStyle: 'short' }) : '-';
+            default:
+                return value.toString();
+        }
+    };
+
+    // Helper function to render individual struct field inputs
+    const renderStructFieldInput = (
+        structField: StructField,
+        inputField: ControllerRenderProps<any, any>,
+    ) => {
+        const isList = structField.list ?? false;
+
+        // Helper function to render single input for a struct field
+        const renderSingleStructInput = (singleField: ControllerRenderProps<any, any>) => {
+            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
+                                field={singleField}
+                                options={stringField.options}
+                                disabled={isReadonly}
+                                isListField={false}
+                            />
+                        );
+                    }
+                    return (
+                        <Input
+                            value={singleField.value ?? ''}
+                            onChange={e => singleField.onChange(e.target.value)}
+                            onBlur={singleField.onBlur}
+                            name={singleField.name}
+                            disabled={isReadonly}
+                        />
+                    );
+                }
+                case 'int':
+                case 'float': {
+                    const isFloat = structField.type === 'float';
+                    const numericField = structField as any; // GraphQL union types need casting
+                    const min = isFloat ? numericField.floatMin : numericField.intMin;
+                    const max = isFloat ? numericField.floatMax : numericField.intMax;
+                    const step = isFloat ? numericField.floatStep : numericField.intStep;
+
+                    return (
+                        <Input
+                            type="number"
+                            value={singleField.value ?? ''}
+                            onChange={e => {
+                                const value = e.target.valueAsNumber;
+                                singleField.onChange(isNaN(value) ? undefined : value);
+                            }}
+                            onBlur={singleField.onBlur}
+                            name={singleField.name}
+                            disabled={isReadonly}
+                            min={min}
+                            max={max}
+                            step={step}
+                        />
+                    );
+                }
+                case 'boolean':
+                    return (
+                        <Switch
+                            checked={singleField.value}
+                            onCheckedChange={singleField.onChange}
+                            disabled={isReadonly}
+                        />
+                    );
+                case 'datetime':
+                    return (
+                        <DateTimeInput
+                            value={singleField.value}
+                            onChange={singleField.onChange}
+                            disabled={isReadonly}
+                        />
+                    );
+                default:
+                    return (
+                        <Input
+                            value={singleField.value ?? ''}
+                            onChange={e => singleField.onChange(e.target.value)}
+                            onBlur={singleField.onBlur}
+                            name={singleField.name}
+                            disabled={isReadonly}
+                        />
+                    );
+            }
+        };
+
+        // 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
+                        field={inputField}
+                        options={stringField.options}
+                        disabled={isReadonly}
+                        isListField={isList}
+                    />
+                );
+            }
+        }
+
+        // For list struct fields, wrap with list input
+        if (isList) {
+            const getDefaultValue = () => {
+                switch (structField.type) {
+                    case 'string':
+                        return '';
+                    case 'int':
+                    case 'float':
+                        return 0;
+                    case 'boolean':
+                        return false;
+                    case 'datetime':
+                        return '';
+                    default:
+                        return '';
+                }
+            };
+
+            // Determine if the field type needs full width
+            const needsFullWidth = structField.type === 'text' || structField.type === 'localeText';
+
+            return (
+                <CustomFieldListInput
+                    field={inputField}
+                    disabled={isReadonly}
+                    renderInput={(index, listItemField) => renderSingleStructInput(listItemField)}
+                    defaultValue={getDefaultValue()}
+                    isFullWidth={needsFullWidth}
+                />
+            );
+        }
+
+        // For non-list fields, render directly
+        return renderSingleStructInput(inputField);
+    };
+
+    // Edit mode - memoized to prevent focus loss from re-renders
+    const EditMode = useMemo(
+        () => (
+            <div className="space-y-4 border rounded-md p-4">
+                {!isReadonly && (
+                    <div className="flex justify-end">
+                        <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => setIsEditing(false)}
+                            className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
+                        >
+                            <CheckIcon className="h-4 w-4" />
+                            <span className="sr-only">Done</span>
+                        </Button>
+                    </div>
+                )}
+                {fieldDef.fields.map(structField => (
+                    <FormField
+                        key={structField.name}
+                        control={control}
+                        name={`${field.name}.${structField.name}`}
+                        render={({ field: structInputField }) => (
+                            <FormItem>
+                                <div className="flex items-baseline gap-4">
+                                    <div className="flex-1">
+                                        <FormLabel>
+                                            {getTranslation(structField.label) ?? structField.name}
+                                        </FormLabel>
+                                        {getTranslation(structField.description) && (
+                                            <FormDescription>
+                                                {getTranslation(structField.description)}
+                                            </FormDescription>
+                                        )}
+                                    </div>
+                                    <div className="flex-[2]">
+                                        <FormControl>
+                                            {renderStructFieldInput(structField, structInputField)}
+                                        </FormControl>
+                                        <FormMessage />
+                                    </div>
+                                </div>
+                            </FormItem>
+                        )}
+                    />
+                ))}
+            </div>
+        ),
+        [fieldDef, control, field.name, getTranslation, renderStructFieldInput, isReadonly],
+    );
+
+    return isEditing ? (
+        EditMode
+    ) : (
+        <DisplayMode
+            fieldDef={fieldDef}
+            watchedStructValue={watchedStructValue}
+            isReadonly={isReadonly}
+            setIsEditing={setIsEditing}
+            getTranslation={getTranslation}
+            formatFieldValue={formatFieldValue}
+        />
+    );
+}

+ 207 - 36
packages/dashboard/src/lib/components/shared/custom-fields-form.tsx

@@ -1,3 +1,8 @@
+import { CustomFieldListInput } from '@/vdb/components/data-input/custom-field-list-input.js';
+import { DateTimeInput } from '@/vdb/components/data-input/datetime-input.js';
+import { DefaultRelationInput } from '@/vdb/components/data-input/default-relation-input.js';
+import { SelectWithOptions } from '@/vdb/components/data-input/select-with-options.js';
+import { StructFormInput } from '@/vdb/components/data-input/struct-form-input.js';
 import {
     FormControl,
     FormDescription,
@@ -13,6 +18,7 @@ import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { useLingui } from '@/vdb/lib/trans.js';
 import { customFieldConfigFragment } from '@/vdb/providers/server-config.js';
+import { StringCustomFieldConfig } from '@vendure/common/lib/generated-types';
 import { CustomFieldType } from '@vendure/common/lib/shared-types';
 import { ResultOf } from 'gql.tada';
 import React, { useMemo } from 'react';
@@ -40,13 +46,15 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Readon
 
     const customFields = useCustomFieldConfig(entityType);
 
+    const getCustomFieldBaseName = (fieldDef: CustomFieldConfig) => {
+        if (fieldDef.type !== 'relation') {
+            return fieldDef.name;
+        }
+        return fieldDef.list ? fieldDef.name + 'Ids' : fieldDef.name + 'Id';
+    };
+
     const getFieldName = (fieldDef: CustomFieldConfig) => {
-        const name =
-            fieldDef.type === 'relation'
-                ? fieldDef.list
-                    ? fieldDef.name + 'Ids'
-                    : fieldDef.name + 'Id'
-                : fieldDef.name;
+        const name = getCustomFieldBaseName(fieldDef);
         return formPathPrefix ? `${formPathPrefix}.customFields.${name}` : `customFields.${name}`;
     };
 
@@ -84,7 +92,7 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Readon
     if (!shouldShowTabs) {
         // Single tab view - use the original grid layout
         return (
-            <div className="grid grid-cols-2 gap-4">
+            <div className="grid @md:grid-cols-2 gap-6">
                 {customFields?.map(fieldDef => (
                     <CustomFieldItem
                         key={fieldDef.name}
@@ -110,7 +118,7 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Readon
             </TabsList>
             {groupedFields.map(group => (
                 <TabsContent key={group.tabName} value={group.tabName} className="mt-4">
-                    <div className="grid grid-cols-2 gap-4">
+                    <div className="grid @md:grid-cols-2 gap-6">
                         {group.customFields.map(fieldDef => (
                             <CustomFieldItem
                                 key={fieldDef.name}
@@ -136,11 +144,12 @@ interface CustomFieldItemProps {
     ) => string | undefined;
 }
 
-function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: CustomFieldItemProps) {
-    const hasCustomFormComponent = fieldDef.ui && fieldDef.ui.component;
+function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Readonly<CustomFieldItemProps>) {
+    const hasCustomFormComponent = fieldDef.ui?.component;
     const isLocaleField = fieldDef.type === 'localeString' || fieldDef.type === 'localeText';
     const shouldBeFullWidth = fieldDef.ui?.fullWidth === true;
     const containerClassName = shouldBeFullWidth ? 'col-span-2' : '';
+    const isReadonly = fieldDef.readonly ?? false;
 
     // For locale fields, always use TranslatableFormField regardless of custom components
     if (isLocaleField) {
@@ -207,6 +216,71 @@ function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Custo
         );
     }
 
+    // For struct fields, use the special struct component
+    if (fieldDef.type === 'struct') {
+        const isList = fieldDef.list ?? false;
+
+        // Handle struct lists - entire struct objects in a list
+        if (isList) {
+            return (
+                <div className={containerClassName}>
+                    <FormField
+                        control={control}
+                        name={fieldName}
+                        render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
+                                <FormControl>
+                                    <CustomFieldListInput
+                                        field={field}
+                                        disabled={isReadonly}
+                                        renderInput={(index, inputField) => (
+                                            <StructFormInput
+                                                field={inputField}
+                                                fieldDef={fieldDef as any}
+                                                control={control}
+                                                getTranslation={getTranslation}
+                                            />
+                                        )}
+                                        defaultValue={{}} // Empty struct object as default
+                                        isFullWidth={true} // Structs should always be full-width
+                                    />
+                                </FormControl>
+                                <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
+                                <FormMessage />
+                            </FormItem>
+                        )}
+                    />
+                </div>
+            );
+        }
+
+        // Handle single struct fields
+        return (
+            <div className={containerClassName}>
+                <FormField
+                    control={control}
+                    name={fieldName}
+                    render={({ field }) => (
+                        <FormItem>
+                            <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
+                            <FormControl>
+                                <StructFormInput
+                                    field={field}
+                                    fieldDef={fieldDef as any}
+                                    control={control}
+                                    getTranslation={getTranslation}
+                                />
+                            </FormControl>
+                            <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
+                            <FormMessage />
+                        </FormItem>
+                    )}
+                />
+            </div>
+        );
+    }
+
     // For regular fields without custom components
     return (
         <div className={containerClassName}>
@@ -236,7 +310,12 @@ interface CustomFieldFormItemProps {
     children: React.ReactNode;
 }
 
-function CustomFieldFormItem({ fieldDef, getTranslation, fieldName, children }: CustomFieldFormItemProps) {
+function CustomFieldFormItem({
+    fieldDef,
+    getTranslation,
+    fieldName,
+    children,
+}: Readonly<CustomFieldFormItemProps>) {
     return (
         <FormItem>
             <FormLabel>{getTranslation(fieldDef.label) ?? fieldName}</FormLabel>
@@ -250,40 +329,132 @@ function CustomFieldFormItem({ fieldDef, getTranslation, fieldName, children }:
 function FormInputForType({
     fieldDef,
     field,
-}: {
+}: Readonly<{
     fieldDef: CustomFieldConfig;
     field: ControllerRenderProps<any, any>;
-}) {
+}>) {
     const isReadonly = fieldDef.readonly ?? false;
+    const isList = fieldDef.list ?? false;
+
+    // Helper function to render individual input components
+    const renderSingleInput = (inputField: ControllerRenderProps<any, any>) => {
+        switch (fieldDef.type as CustomFieldType) {
+            case 'float':
+            case 'int': {
+                const numericFieldDef = fieldDef as any;
+                const isFloat = fieldDef.type === 'float';
+                const min = isFloat ? numericFieldDef.floatMin : numericFieldDef.intMin;
+                const max = isFloat ? numericFieldDef.floatMax : numericFieldDef.intMax;
+                const step = isFloat ? numericFieldDef.floatStep : numericFieldDef.intStep;
 
-    switch (fieldDef.type as CustomFieldType) {
-        case 'string':
-            return <Input {...field} disabled={isReadonly} />;
-        case 'float':
-        case 'int':
-            return (
-                <Input
-                    type="number"
-                    {...field}
-                    disabled={isReadonly}
-                    onChange={e => field.onChange(e.target.valueAsNumber)}
-                />
-            );
-        case 'boolean':
-            return <Switch checked={field.value} onCheckedChange={field.onChange} disabled={isReadonly} />;
-        case 'relation':
-            if (fieldDef.list) {
                 return (
                     <Input
-                        {...field}
-                        onChange={e => field.onChange(e.target.value.split(','))}
+                        type="number"
+                        value={inputField.value ?? ''}
+                        onChange={e => {
+                            const value = e.target.valueAsNumber;
+                            inputField.onChange(isNaN(value) ? undefined : value);
+                        }}
+                        onBlur={inputField.onBlur}
+                        name={inputField.name}
+                        disabled={isReadonly}
+                        min={min}
+                        max={max}
+                        step={step}
+                    />
+                );
+            }
+            case 'boolean':
+                return (
+                    <Switch
+                        checked={inputField.value}
+                        onCheckedChange={inputField.onChange}
+                        disabled={isReadonly}
+                    />
+                );
+            case 'datetime': {
+                return (
+                    <DateTimeInput
+                        value={inputField.value}
+                        onChange={inputField.onChange}
                         disabled={isReadonly}
                     />
                 );
-            } else {
-                return <Input {...field} disabled={isReadonly} />;
             }
-        default:
-            return <Input {...field} disabled={isReadonly} />;
+            case 'struct':
+                // Struct fields need special handling and can't be rendered as simple inputs
+                return null;
+            case 'string':
+            default:
+                return (
+                    <Input
+                        value={inputField.value ?? ''}
+                        onChange={e => inputField.onChange(e.target.value)}
+                        onBlur={inputField.onBlur}
+                        name={inputField.name}
+                        disabled={isReadonly}
+                    />
+                );
+        }
+    };
+
+    // Handle struct fields with special component
+    if (fieldDef.type === 'struct') {
+        // We need access to the control and getTranslation function
+        // This will need to be passed down from the parent component
+        return null; // Placeholder - struct fields are handled differently in the parent
     }
+
+    // Handle relation fields directly (they handle list/single internally)
+    if (fieldDef.type === 'relation') {
+        return <DefaultRelationInput fieldDef={fieldDef as any} field={field} disabled={isReadonly} />;
+    }
+
+    // Handle string fields with options (dropdown) - already handles list case with multi-select
+    if (fieldDef.type === 'string') {
+        const options = (fieldDef as StringCustomFieldConfig).options;
+        if (options && options.length > 0) {
+            return (
+                <SelectWithOptions
+                    field={field}
+                    options={options}
+                    disabled={isReadonly}
+                    isListField={isList}
+                />
+            );
+        }
+    }
+
+    // For list fields (except string with options and relations which are handled above), wrap with list input
+    if (isList) {
+        const getDefaultValue = () => {
+            switch (fieldDef.type as CustomFieldType) {
+                case 'string':
+                    return '';
+                case 'int':
+                case 'float':
+                    return 0;
+                case 'boolean':
+                    return false;
+                case 'datetime':
+                    return '';
+                case 'relation':
+                    return '';
+                default:
+                    return '';
+            }
+        };
+
+        return (
+            <CustomFieldListInput
+                field={field}
+                disabled={isReadonly}
+                renderInput={(index, inputField) => renderSingleInput(inputField)}
+                defaultValue={getDefaultValue()}
+            />
+        );
+    }
+
+    // For non-list fields, render directly
+    return renderSingleInput(field);
 }

+ 1 - 1
packages/dashboard/src/lib/components/shared/multi-select.tsx

@@ -71,7 +71,7 @@ export function MultiSelect<T extends boolean>(props: MultiSelectProps<T>) {
                     className={cn(
                         'w-full justify-between',
                         'min-h-[2.5rem] h-auto',
-                        'flex flex-wrap gap-1 p-1',
+                        'flex flex-wrap gap-1 p-1 shadow-xs',
                         className,
                     )}
                 >

+ 4 - 4
packages/dashboard/src/lib/components/ui/form.tsx

@@ -3,12 +3,12 @@ import { Slot } from '@radix-ui/react-slot';
 import * as React from 'react';
 import {
     Controller,
-    FormProvider,
-    useFormContext,
-    useFormState,
     type ControllerProps,
     type FieldPath,
     type FieldValues,
+    FormProvider,
+    useFormContext,
+    useFormState,
 } from 'react-hook-form';
 
 import { Label } from '@/vdb/components/ui/label.js';
@@ -112,7 +112,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
         <p
             data-slot="form-description"
             id={formDescriptionId}
-            className={cn('text-muted-foreground text-sm', className)}
+            className={cn('text-muted-foreground text-xs', className)}
             {...props}
         />
     );

+ 472 - 0
packages/dashboard/src/lib/framework/form-engine/form-schema-tools.spec.ts

@@ -0,0 +1,472 @@
+import { FieldInfo } from '@/vdb/framework/document-introspection/get-document-structure.js';
+import { describe, expect, it } from 'vitest';
+
+import { createFormSchemaFromFields, getZodTypeFromField } from './form-schema-tools.js';
+
+// Helper to create mock FieldInfo
+const createMockField = (
+    name: string,
+    type: string,
+    nullable = false,
+    list = false,
+    typeInfo?: FieldInfo[],
+): FieldInfo => ({
+    name,
+    type,
+    nullable,
+    list,
+    typeInfo,
+    isPaginatedList: false,
+    isScalar: false,
+});
+
+// Helper to create mock CustomFieldConfig
+const createMockCustomField = (
+    name: string,
+    type: string,
+    options: {
+        pattern?: string;
+        intMin?: number;
+        intMax?: number;
+        floatMin?: number;
+        floatMax?: number;
+        datetimeMin?: string;
+        datetimeMax?: string;
+        list?: boolean;
+        nullable?: boolean;
+    } = {},
+) => ({
+    name,
+    type,
+    ...options,
+});
+
+describe('form-schema-tools', () => {
+    describe('getZodTypeFromField', () => {
+        it('should create string type for String fields', () => {
+            const field = createMockField('name', 'String');
+            const schema = getZodTypeFromField(field);
+
+            expect(() => schema.parse('test')).not.toThrow();
+            expect(() => schema.parse(123)).toThrow();
+        });
+
+        it('should create number type for Int fields', () => {
+            const field = createMockField('age', 'Int');
+            const schema = getZodTypeFromField(field);
+
+            expect(() => schema.parse(25)).not.toThrow();
+            expect(() => schema.parse('25')).toThrow();
+        });
+
+        it('should create number type for Float fields', () => {
+            const field = createMockField('price', 'Float');
+            const schema = getZodTypeFromField(field);
+
+            expect(() => schema.parse(29.99)).not.toThrow();
+            expect(() => schema.parse('29.99')).toThrow();
+        });
+
+        it('should create boolean type for Boolean fields', () => {
+            const field = createMockField('active', 'Boolean');
+            const schema = getZodTypeFromField(field);
+
+            expect(() => schema.parse(true)).not.toThrow();
+            expect(() => schema.parse('true')).toThrow();
+        });
+
+        it('should handle nullable fields', () => {
+            const field = createMockField('optional', 'String', true);
+            const schema = getZodTypeFromField(field);
+
+            expect(() => schema.parse('test')).not.toThrow();
+            expect(() => schema.parse(null)).not.toThrow();
+            expect(() => schema.parse(undefined)).not.toThrow();
+        });
+
+        it('should handle list fields', () => {
+            const field = createMockField('tags', 'String', false, true);
+            const schema = getZodTypeFromField(field);
+
+            expect(() => schema.parse(['tag1', 'tag2'])).not.toThrow();
+            expect(() => schema.parse('tag1')).toThrow();
+        });
+    });
+
+    describe('createFormSchemaFromFields - basic functionality', () => {
+        it('should create schema for simple fields', () => {
+            const fields = [
+                createMockField('name', 'String'),
+                createMockField('age', 'Int'),
+                createMockField('active', 'Boolean'),
+            ];
+
+            const schema = createFormSchemaFromFields(fields);
+
+            const validData = { name: 'John', age: 25, active: true };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const invalidData = { name: 123, age: 'twenty', active: 'yes' };
+            expect(() => schema.parse(invalidData)).toThrow();
+        });
+
+        it('should handle nested objects', () => {
+            const fields = [
+                createMockField('user', 'Object', false, false, [
+                    createMockField('name', 'String'),
+                    createMockField('email', 'String'),
+                ]),
+            ];
+
+            const schema = createFormSchemaFromFields(fields);
+
+            const validData = {
+                user: {
+                    name: 'John',
+                    email: 'john@example.com',
+                },
+            };
+            expect(() => schema.parse(validData)).not.toThrow();
+        });
+    });
+
+    describe('createFormSchemaFromFields - custom fields (root context)', () => {
+        it('should apply string pattern validation for root custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('sku', 'string', { pattern: '^[A-Z]{2}-\\d{4}$' })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = { customFields: { sku: 'AB-1234' } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const invalidData = { customFields: { sku: 'invalid-sku' } };
+            expect(() => schema.parse(invalidData)).toThrow();
+        });
+
+        it('should apply int min/max validation for root custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('quantity', 'int', { intMin: 1, intMax: 100 })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = { customFields: { quantity: 50 } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const belowMinData = { customFields: { quantity: 0 } };
+            expect(() => schema.parse(belowMinData)).toThrow();
+
+            const aboveMaxData = { customFields: { quantity: 101 } };
+            expect(() => schema.parse(aboveMaxData)).toThrow();
+        });
+
+        it('should apply float min/max validation for root custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [
+                createMockCustomField('weight', 'float', { floatMin: 0.1, floatMax: 999.9 }),
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = { customFields: { weight: 10.5 } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const belowMinData = { customFields: { weight: 0.05 } };
+            expect(() => schema.parse(belowMinData)).toThrow();
+
+            const aboveMaxData = { customFields: { weight: 1000.0 } };
+            expect(() => schema.parse(aboveMaxData)).toThrow();
+        });
+
+        it('should apply datetime min/max validation for root custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [
+                createMockCustomField('releaseDate', 'datetime', {
+                    datetimeMin: '2020-01-01T00:00:00.000Z',
+                    datetimeMax: '2025-12-31T23:59:59.999Z',
+                }),
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            // Test with string
+            const validDataString = { customFields: { releaseDate: '2023-06-15T12:00:00.000Z' } };
+            expect(() => schema.parse(validDataString)).not.toThrow();
+
+            // Test with Date object
+            const validDataDate = { customFields: { releaseDate: new Date('2023-06-15T12:00:00.000Z') } };
+            expect(() => schema.parse(validDataDate)).not.toThrow();
+
+            const beforeMinData = { customFields: { releaseDate: '2019-12-31T23:59:59.999Z' } };
+            expect(() => schema.parse(beforeMinData)).toThrow();
+
+            const afterMaxData = { customFields: { releaseDate: '2026-01-01T00:00:00.000Z' } };
+            expect(() => schema.parse(afterMaxData)).toThrow();
+        });
+
+        it('should handle boolean custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('featured', 'boolean')];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = { customFields: { featured: true } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const invalidData = { customFields: { featured: 'yes' } };
+            expect(() => schema.parse(invalidData)).toThrow();
+        });
+
+        it('should handle list custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('tags', 'string', { list: true })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = { customFields: { tags: ['tag1', 'tag2'] } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const invalidData = { customFields: { tags: 'single-tag' } };
+            expect(() => schema.parse(invalidData)).toThrow();
+        });
+
+        it('should handle nullable custom fields', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('optionalField', 'string', { nullable: true })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = { customFields: { optionalField: 'value' } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const nullData = { customFields: { optionalField: null } };
+            expect(() => schema.parse(nullData)).not.toThrow();
+
+            const undefinedData = { customFields: { optionalField: undefined } };
+            expect(() => schema.parse(undefinedData)).not.toThrow();
+        });
+
+        it('should only include non-translatable fields in root context', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [
+                createMockCustomField('sku', 'string'), // Should be included
+                createMockCustomField('description', 'localeString'), // Should be excluded
+                createMockCustomField('content', 'localeText'), // Should be excluded
+                createMockCustomField('quantity', 'int'), // Should be included
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            // Should accept non-translatable fields
+            const validData = { customFields: { sku: 'AB-123', quantity: 5 } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            // Should reject translatable fields in root context
+            const withTranslatableData = {
+                customFields: {
+                    sku: 'AB-123',
+                    description: 'Some description', // This should cause validation to fail
+                },
+            };
+            // Note: This might not throw because Zod ignores extra properties by default
+            // The important thing is that the schema doesn't validate translatable fields
+        });
+    });
+
+    describe('createFormSchemaFromFields - custom fields (translation context)', () => {
+        it('should handle localeString custom fields in translation context', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [
+                createMockCustomField('description', 'localeString', { pattern: '^[A-Za-z\\s]+$' }),
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, true);
+
+            const validData = { customFields: { description: 'Valid Description' } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const invalidData = { customFields: { description: 'Invalid123' } };
+            expect(() => schema.parse(invalidData)).toThrow();
+        });
+
+        it('should handle localeText custom fields in translation context', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('content', 'localeText')];
+
+            const schema = createFormSchemaFromFields(fields, customFields, true);
+
+            const validData = { customFields: { content: 'Some long text content' } };
+            expect(() => schema.parse(validData)).not.toThrow();
+
+            const invalidData = { customFields: { content: 123 } };
+            expect(() => schema.parse(invalidData)).toThrow();
+        });
+
+        it('should only include translatable fields in translation context', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [
+                createMockCustomField('sku', 'string'), // Should be excluded
+                createMockCustomField('description', 'localeString'), // Should be included
+                createMockCustomField('content', 'localeText'), // Should be included
+                createMockCustomField('quantity', 'int'), // Should be excluded
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, true);
+
+            // Should accept translatable fields
+            const validData = {
+                customFields: {
+                    description: 'Description',
+                    content: 'Content',
+                },
+            };
+            expect(() => schema.parse(validData)).not.toThrow();
+        });
+    });
+
+    describe('createFormSchemaFromFields - translation handling', () => {
+        it('should handle translations field with custom fields', () => {
+            const fields = [
+                createMockField('name', 'String'),
+                createMockField('translations', 'Object', false, true, [
+                    createMockField('id', 'ID'),
+                    createMockField('languageCode', 'String'),
+                    createMockField('name', 'String'),
+                    createMockField('customFields', 'Object', false, false, []),
+                ]),
+                createMockField('customFields', 'Object', false, false, []),
+            ];
+            const customFields = [
+                createMockCustomField('sku', 'string'), // Root custom field
+                createMockCustomField('description', 'localeString'), // Translation custom field
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            const validData = {
+                name: 'Product Name',
+                customFields: { sku: 'AB-123' }, // Root custom fields
+                translations: [
+                    {
+                        id: '1',
+                        languageCode: 'en',
+                        name: 'English Name',
+                        customFields: { description: 'English description' }, // Translation custom fields
+                    },
+                ],
+            };
+
+            expect(() => schema.parse(validData)).not.toThrow();
+        });
+    });
+
+    describe('createFormSchemaFromFields - error messages', () => {
+        it('should provide clear error messages for pattern validation', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('sku', 'string', { pattern: '^[A-Z]{2}-\\d{4}$' })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            try {
+                schema.parse({ customFields: { sku: 'invalid' } });
+                expect.fail('Should have thrown validation error');
+            } catch (error: any) {
+                expect(error.errors[0].message).toContain('Value must match pattern');
+            }
+        });
+
+        it('should provide clear error messages for min validation', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('quantity', 'int', { intMin: 1 })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            try {
+                schema.parse({ customFields: { quantity: 0 } });
+                expect.fail('Should have thrown validation error');
+            } catch (error: any) {
+                expect(error.errors[0].message).toContain('Value must be at least 1');
+            }
+        });
+
+        it('should provide clear error messages for max validation', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [createMockCustomField('quantity', 'int', { intMax: 100 })];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            try {
+                schema.parse({ customFields: { quantity: 101 } });
+                expect.fail('Should have thrown validation error');
+            } catch (error: any) {
+                expect(error.errors[0].message).toContain('Value must be at most 100');
+            }
+        });
+
+        it('should provide clear error messages for datetime validation', () => {
+            const fields = [createMockField('customFields', 'Object', false, false, [])];
+            const customFields = [
+                createMockCustomField('releaseDate', 'datetime', {
+                    datetimeMin: '2020-01-01T00:00:00.000Z',
+                }),
+            ];
+
+            const schema = createFormSchemaFromFields(fields, customFields, false);
+
+            try {
+                schema.parse({ customFields: { releaseDate: '2019-12-31T23:59:59.999Z' } });
+                expect.fail('Should have thrown validation error');
+            } catch (error: any) {
+                expect(error.issues).toBeDefined();
+                expect(error.issues.length).toBeGreaterThan(0);
+                expect(error.issues[0].message).toContain('Date must be after');
+            }
+
+            // Test with Date object as well
+            try {
+                schema.parse({ customFields: { releaseDate: new Date('2019-12-31T23:59:59.999Z') } });
+                expect.fail('Should have thrown validation error');
+            } catch (error: any) {
+                expect(error.issues).toBeDefined();
+                expect(error.issues.length).toBeGreaterThan(0);
+                expect(error.issues[0].message).toContain('Date must be after');
+            }
+        });
+    });
+
+    describe('createFormSchemaFromFields - edge cases', () => {
+        it('should handle empty custom field config', () => {
+            const fields = [
+                createMockField('name', 'String'),
+                createMockField('customFields', 'Object', false, false, []),
+            ];
+
+            const schema = createFormSchemaFromFields(fields, []);
+
+            const validData = { name: 'Test', customFields: {} };
+            expect(() => schema.parse(validData)).not.toThrow();
+        });
+
+        it('should handle no custom field config', () => {
+            const fields = [
+                createMockField('name', 'String'),
+                createMockField('customFields', 'Object', false, false, []),
+            ];
+
+            const schema = createFormSchemaFromFields(fields);
+
+            const validData = { name: 'Test' };
+            expect(() => schema.parse(validData)).not.toThrow();
+        });
+
+        it('should handle fields without customFields', () => {
+            const fields = [createMockField('name', 'String'), createMockField('age', 'Int')];
+            const customFields = [createMockCustomField('sku', 'string')];
+
+            const schema = createFormSchemaFromFields(fields, customFields);
+
+            const validData = { name: 'Test', age: 25 };
+            expect(() => schema.parse(validData)).not.toThrow();
+        });
+    });
+});

+ 340 - 5
packages/dashboard/src/lib/framework/form-engine/form-schema-tools.ts

@@ -3,26 +3,348 @@ import {
     isEnumType,
     isScalarType,
 } from '@/vdb/framework/document-introspection/get-document-structure.js';
+import { StructCustomFieldConfig } from '@vendure/common/lib/generated-types';
+import { ResultOf } from 'gql.tada';
 import { z, ZodRawShape, ZodType, ZodTypeAny } from 'zod';
 
-export function createFormSchemaFromFields(fields: FieldInfo[]) {
+import { structCustomFieldFragment } from '../../providers/server-config.js';
+
+type CustomFieldConfig = {
+    name: string;
+    type: string;
+    pattern?: string;
+    intMin?: number;
+    intMax?: number;
+    floatMin?: number;
+    floatMax?: number;
+    datetimeMin?: string;
+    datetimeMax?: string;
+    list?: boolean;
+    nullable?: boolean;
+};
+
+type StructFieldConfig = ResultOf<typeof structCustomFieldFragment>['fields'][number];
+
+function mapGraphQLCustomFieldToConfig(field: StructFieldConfig): CustomFieldConfig {
+    const baseConfig = {
+        name: field.name,
+        type: field.type,
+        list: field.list ?? false,
+        nullable: true, // Default to true since GraphQL fields are nullable by default
+    };
+
+    switch (field.__typename) {
+        case 'StringStructFieldConfig':
+            return {
+                ...baseConfig,
+                pattern: field.pattern ?? undefined,
+            };
+        case 'IntStructFieldConfig':
+            return {
+                ...baseConfig,
+                intMin: field.intMin ?? undefined,
+                intMax: field.intMax ?? undefined,
+            };
+        case 'FloatStructFieldConfig':
+            return {
+                ...baseConfig,
+                floatMin: field.floatMin ?? undefined,
+                floatMax: field.floatMax ?? undefined,
+            };
+        case 'DateTimeStructFieldConfig':
+            return {
+                ...baseConfig,
+                datetimeMin: field.datetimeMin ?? undefined,
+                datetimeMax: field.datetimeMax ?? undefined,
+            };
+        default:
+            return baseConfig;
+    }
+}
+
+/**
+ * Safely parses a date string into a Date object.
+ * Used for parsing datetime constraints in custom field validation.
+ *
+ * @param dateStr - The date string to parse
+ * @returns Parsed Date object or undefined if invalid
+ */
+function parseDate(dateStr: string | undefined | null): Date | undefined {
+    if (!dateStr) return undefined;
+    const date = new Date(dateStr);
+    return isNaN(date.getTime()) ? undefined : date;
+}
+
+/**
+ * Creates a Zod validation schema for datetime fields with optional min/max constraints.
+ * Supports both string and Date inputs, which is common in form handling.
+ *
+ * @param minDate - Optional minimum date constraint
+ * @param maxDate - Optional maximum date constraint
+ * @returns Zod schema that validates date ranges
+ */
+function createDateValidationSchema(minDate: Date | undefined, maxDate: Date | undefined): ZodType {
+    const baseSchema = z.union([z.string(), z.date()]);
+    if (!minDate && !maxDate) return baseSchema;
+
+    const dateMinString = minDate?.toLocaleDateString() ?? '';
+    const dateMaxString = maxDate?.toLocaleDateString() ?? '';
+    const dateMinMessage = minDate ? `Date must be after ${dateMinString}` : '';
+    const dateMaxMessage = maxDate ? `Date must be before ${dateMaxString}` : '';
+
+    return baseSchema.refine(
+        val => {
+            if (!val) return true;
+            const date = val instanceof Date ? val : new Date(val);
+            if (minDate && date < minDate) return false;
+            if (maxDate && date > maxDate) return false;
+            return true;
+        },
+        val => {
+            const date = val instanceof Date ? val : new Date(val);
+            if (minDate && date < minDate) return { message: dateMinMessage };
+            if (maxDate && date > maxDate) return { message: dateMaxMessage };
+            return { message: '' };
+        },
+    );
+}
+
+/**
+ * Creates a Zod validation schema for string fields with optional regex pattern validation.
+ * Used for string-type custom fields that may have pattern constraints.
+ *
+ * @param pattern - Optional regex pattern string for validation
+ * @returns Zod string schema with optional pattern validation
+ */
+function createStringValidationSchema(pattern?: string): ZodType {
+    let schema = z.string();
+    if (pattern) {
+        schema = schema.regex(new RegExp(pattern), {
+            message: `Value must match pattern: ${pattern}`,
+        });
+    }
+    return schema;
+}
+
+/**
+ * Creates a Zod validation schema for integer fields with optional min/max constraints.
+ * Used for int-type custom fields that may have numeric range limits.
+ *
+ * @param min - Optional minimum value constraint
+ * @param max - Optional maximum value constraint
+ * @returns Zod number schema with optional range validation
+ */
+function createIntValidationSchema(min?: number, max?: number): ZodType {
+    let schema = z.number();
+    if (min !== undefined) {
+        schema = schema.min(min, {
+            message: `Value must be at least ${min}`,
+        });
+    }
+    if (max !== undefined) {
+        schema = schema.max(max, {
+            message: `Value must be at most ${max}`,
+        });
+    }
+    return schema;
+}
+
+/**
+ * Creates a Zod validation schema for float fields with optional min/max constraints.
+ * Used for float-type custom fields that may have numeric range limits.
+ *
+ * @param min - Optional minimum value constraint
+ * @param max - Optional maximum value constraint
+ * @returns Zod number schema with optional range validation
+ */
+function createFloatValidationSchema(min?: number, max?: number): ZodType {
+    let schema = z.number();
+    if (min !== undefined) {
+        schema = schema.min(min, {
+            message: `Value must be at least ${min}`,
+        });
+    }
+    if (max !== undefined) {
+        schema = schema.max(max, {
+            message: `Value must be at most ${max}`,
+        });
+    }
+    return schema;
+}
+
+/**
+ * Creates a Zod validation schema for a single custom field based on its type and constraints.
+ * This is the main dispatcher that routes different custom field types to their specific
+ * validation schema creators. Handles all standard custom field types in Vendure.
+ *
+ * @param customField - The custom field configuration object
+ * @returns Zod schema appropriate for the custom field type
+ */
+function createCustomFieldValidationSchema(customField: CustomFieldConfig): ZodType {
+    let zodType: ZodType;
+
+    switch (customField.type) {
+        case 'localeString':
+        case 'localeText':
+        case 'string':
+            zodType = createStringValidationSchema(customField.pattern);
+            break;
+        case 'int':
+            zodType = createIntValidationSchema(customField.intMin, customField.intMax);
+            break;
+        case 'float':
+            zodType = createFloatValidationSchema(customField.floatMin, customField.floatMax);
+            break;
+        case 'datetime': {
+            const minDate = parseDate(customField.datetimeMin);
+            const maxDate = parseDate(customField.datetimeMax);
+            zodType = createDateValidationSchema(minDate, maxDate);
+            break;
+        }
+        case 'boolean':
+            zodType = z.boolean();
+            break;
+        default:
+            zodType = z.any();
+            break;
+    }
+
+    return zodType;
+}
+
+/**
+ * Creates a Zod validation schema for struct-type custom fields.
+ * Struct fields contain nested sub-fields, each with their own validation rules.
+ * This recursively processes each sub-field to create a nested object schema.
+ *
+ * @param structFieldConfig - The struct custom field configuration with nested fields
+ * @returns Zod object schema representing the struct with all sub-field validations
+ */
+function createStructFieldSchema(structFieldConfig: StructCustomFieldConfig): ZodType {
+    if (!structFieldConfig.fields || !Array.isArray(structFieldConfig.fields)) {
+        return z.object({}).passthrough();
+    }
+
+    const nestedSchema: ZodRawShape = {};
+    for (const structSubField of structFieldConfig.fields) {
+        const config = mapGraphQLCustomFieldToConfig(structSubField as StructFieldConfig);
+        let subFieldType = createCustomFieldValidationSchema(config);
+
+        // Handle list and nullable for struct sub-fields
+        if (config.list) {
+            subFieldType = z.array(subFieldType);
+        }
+        if (config.nullable) {
+            subFieldType = subFieldType.optional().nullable();
+        }
+
+        nestedSchema[config.name] = subFieldType;
+    }
+
+    return z.object(nestedSchema);
+}
+
+/**
+ * Applies common list and nullable modifiers to a Zod schema based on custom field configuration.
+ * Many custom fields can be configured as lists (arrays) and/or nullable, so this helper
+ * centralizes that logic to avoid duplication.
+ *
+ * @param zodType - The base Zod schema to modify
+ * @param customField - Custom field config containing list/nullable flags
+ * @returns Modified Zod schema with list/nullable modifiers applied
+ */
+function applyListAndNullableModifiers(zodType: ZodType, customField: CustomFieldConfig): ZodType {
+    let modifiedType = zodType;
+
+    if (customField.list) {
+        modifiedType = z.array(modifiedType);
+    }
+    if (customField.nullable !== false) {
+        modifiedType = modifiedType.optional().nullable();
+    }
+
+    return modifiedType;
+}
+
+/**
+ * Processes all custom fields and creates a complete validation schema for the customFields object.
+ * Handles context-aware filtering (translation vs root context) and orchestrates the creation
+ * of validation schemas for all custom field types including complex struct fields.
+ *
+ * @param customFieldConfigs - Array of all custom field configurations
+ * @param isTranslationContext - Whether we're processing fields for translation forms
+ * @returns Zod schema shape for the entire customFields object
+ */
+function processCustomFieldsSchema(
+    customFieldConfigs: CustomFieldConfig[],
+    isTranslationContext: boolean,
+): ZodRawShape {
+    const customFieldsSchema: ZodRawShape = {};
+    const translatableTypes = ['localeString', 'localeText'];
+
+    const filteredCustomFields = customFieldConfigs.filter(cf => {
+        if (isTranslationContext) {
+            return translatableTypes.includes(cf.type);
+        } else {
+            return !translatableTypes.includes(cf.type);
+        }
+    });
+
+    for (const customField of filteredCustomFields) {
+        let zodType: ZodType;
+
+        if (customField.type === 'struct') {
+            zodType = createStructFieldSchema(customField as StructCustomFieldConfig);
+        } else {
+            zodType = createCustomFieldValidationSchema(customField);
+        }
+
+        zodType = applyListAndNullableModifiers(zodType, customField);
+        const schemaPropertyName = getGraphQlInputName(customField);
+        customFieldsSchema[schemaPropertyName] = zodType;
+    }
+
+    return customFieldsSchema;
+}
+
+export function createFormSchemaFromFields(
+    fields: FieldInfo[],
+    customFieldConfigs?: CustomFieldConfig[],
+    isTranslationContext = false,
+) {
     const schemaConfig: ZodRawShape = {};
+
     for (const field of fields) {
         const isScalar = isScalarType(field.type);
         const isEnum = isEnumType(field.type);
-        if (isScalar || isEnum) {
-            schemaConfig[field.name] = getZodTypeFromField(field);
+
+        if ((isScalar || isEnum) && field.name !== 'customFields') {
+            schemaConfig[field.name] = getZodTypeFromField(field, customFieldConfigs);
+        } else if (field.name === 'customFields') {
+            const customFieldsSchema =
+                customFieldConfigs && customFieldConfigs.length > 0
+                    ? processCustomFieldsSchema(customFieldConfigs, isTranslationContext)
+                    : {};
+            schemaConfig[field.name] = z.object(customFieldsSchema).optional();
         } else if (field.typeInfo) {
-            let nestedType: ZodType = createFormSchemaFromFields(field.typeInfo);
+            const isNestedTranslationContext = field.name === 'translations' || isTranslationContext;
+            let nestedType: ZodType = createFormSchemaFromFields(
+                field.typeInfo,
+                customFieldConfigs,
+                isNestedTranslationContext,
+            );
+
             if (field.nullable) {
                 nestedType = nestedType.optional().nullable();
             }
             if (field.list) {
                 nestedType = z.array(nestedType);
             }
+
             schemaConfig[field.name] = nestedType;
         }
     }
+
     return z.object(schemaConfig);
 }
 
@@ -69,8 +391,12 @@ export function getDefaultValueFromField(field: FieldInfo, defaultLanguageCode?:
     }
 }
 
-export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
+export function getZodTypeFromField(field: FieldInfo, customFieldConfigs?: CustomFieldConfig[]): ZodTypeAny {
     let zodType: ZodType;
+
+    // This function is only used for non-custom fields, so we don't need custom field logic here
+    // Custom fields are handled separately in createFormSchemaFromFields
+
     switch (field.type) {
         case 'String':
         case 'ID':
@@ -88,6 +414,7 @@ export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
         default:
             zodType = z.any();
     }
+
     if (field.list) {
         zodType = z.array(zodType);
     }
@@ -96,3 +423,11 @@ export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
     }
     return zodType;
 }
+
+export function getGraphQlInputName(config: { name: string; type: string; list?: boolean }): string {
+    if (config.type === 'relation') {
+        return config.list === true ? `${config.name}Ids` : `${config.name}Id`;
+    } else {
+        return config.name;
+    }
+}

+ 24 - 8
packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx

@@ -17,6 +17,7 @@ export interface GeneratedFormOptions<
     document?: T;
     varName?: VarName;
     entity: E | null | undefined;
+    customFieldConfig?: any[]; // Add custom field config for validation
     setValues: (
         entity: NonNullable<E>,
     ) => VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>;
@@ -37,14 +38,20 @@ export function useGeneratedForm<
     VarName extends keyof VariablesOf<T> | undefined,
     E extends Record<string, any> = Record<string, any>,
 >(options: GeneratedFormOptions<T, VarName, E>) {
-    const { document, entity, setValues, onSubmit, varName } = options;
+    const { document, entity, setValues, onSubmit, varName, customFieldConfig } = options;
     const { activeChannel } = useChannel();
-    const availableLanguages = useServerConfig()?.availableLanguages || [];
+    const serverConfig = useServerConfig();
+    const availableLanguages = serverConfig?.availableLanguages || [];
     const updateFields = document ? getOperationVariablesFields(document, varName) : [];
-    const schema = createFormSchemaFromFields(updateFields);
+
+    const schema = createFormSchemaFromFields(updateFields, customFieldConfig);
     const defaultValues = getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode);
     const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages, defaultValues);
 
+    const values = processedEntity
+        ? transformRelationFields(updateFields, setValues(processedEntity))
+        : defaultValues;
+
     const form = useForm({
         resolver: async (values, context, options) => {
             const result = await zodResolver(schema)(values, context, options);
@@ -55,15 +62,24 @@ export function useGeneratedForm<
         },
         mode: 'onChange',
         defaultValues,
-        values: processedEntity
-            ? transformRelationFields(updateFields, setValues(processedEntity))
-            : defaultValues,
+        values,
     });
-    let submitHandler = (event: FormEvent) => {
+    let submitHandler = (event: FormEvent): any => {
         event.preventDefault();
     };
     if (onSubmit) {
-        submitHandler = (event: FormEvent) => {
+        submitHandler = async (event: FormEvent) => {
+            event.preventDefault();
+
+            // Trigger validation on ALL fields, not just dirty ones
+            const isValid = await form.trigger();
+
+            if (!isValid) {
+                console.log(`Form invalid!`);
+                event.stopPropagation();
+                return;
+            }
+
             const onSubmitWrapper = (values: any) => {
                 onSubmit(removeEmptyIdFields(values, updateFields));
             };

+ 3 - 9
packages/dashboard/src/lib/framework/form-engine/utils.ts

@@ -10,7 +10,7 @@ import { FieldInfo } from '../document-introspection/get-document-structure.js';
  */
 export function transformRelationFields<E extends Record<string, any>>(fields: FieldInfo[], entity: E): E {
     // Create a shallow copy to avoid mutating the original entity
-    const processedEntity = { ...entity };
+    const processedEntity = { ...entity, customFields: { ...(entity.customFields ?? {}) } };
 
     // Skip processing if there are no custom fields
     if (!entity.customFields || !processedEntity.customFields) {
@@ -44,16 +44,10 @@ export function transformRelationFields<E extends Record<string, any>>(fields: F
             // For single fields, the accessor is the field name without the "Id" suffix
             const propertyAccessorKey = customField.name.replace(/Id$/, '');
             const relationValue = entity.customFields[propertyAccessorKey];
-
-            if (relationValue) {
-                const relationIdValue = relationValue.id;
-                if (relationIdValue) {
-                    processedEntity.customFields[relationField] = relationIdValue;
-                }
-            }
+            processedEntity.customFields[relationField] = relationValue?.id;
+            delete processedEntity.customFields[propertyAccessorKey];
         }
     }
-
     return processedEntity;
 }
 

+ 11 - 3
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -242,7 +242,7 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
 }
 
 export function DetailFormGrid({ children }: Readonly<{ children: React.ReactNode }>) {
-    return <div className="md:grid md:grid-cols-2 gap-4 items-start mb-4">{children}</div>;
+    return <div className="grid @md:grid-cols-2 gap-6 items-start mb-6">{children}</div>;
 }
 
 /**
@@ -412,11 +412,19 @@ export function PageBlock({
     blockId,
     column,
 }: Readonly<PageBlockProps>) {
-    const contextValue = useMemo(() => ({ blockId, title, description, column }), [blockId, title, description, column]);
+    const contextValue = useMemo(
+        () => ({
+            blockId,
+            title,
+            description,
+            column,
+        }),
+        [blockId, title, description, column],
+    );
     return (
         <PageBlockContext.Provider value={contextValue}>
             <LocationWrapper>
-                <Card className={cn('w-full', className)}>
+                <Card className={cn('@container  w-full', className)}>
                     {title || description ? (
                         <CardHeader>
                             {title && <CardTitle>{title}</CardTitle>}

+ 3 - 3
packages/dashboard/src/lib/framework/page/use-detail-page.ts

@@ -1,4 +1,4 @@
-import { removeReadonlyCustomFields } from '@/vdb/lib/utils.js';
+import { removeReadonlyAndLocalizedCustomFields } from '@/vdb/lib/utils.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import {
     DefinedInitialDataOptions,
@@ -307,10 +307,10 @@ export function useDetailPage<
         document,
         varName: 'input',
         entity,
+        customFieldConfig,
         setValues: setValuesForUpdate,
         onSubmit(values: any) {
-            // Filter out readonly custom fields before submitting
-            const filteredValues = removeReadonlyCustomFields(values, customFieldConfig || []);
+            const filteredValues = removeReadonlyAndLocalizedCustomFields(values, customFieldConfig || []);
 
             if (isNew) {
                 const finalInput = transformCreateInput?.(filteredValues) ?? filteredValues;

+ 26 - 24
packages/dashboard/src/lib/lib/utils.ts

@@ -1,4 +1,4 @@
-import { clsx, type ClassValue } from 'clsx';
+import { type ClassValue, clsx } from 'clsx';
 import { twMerge } from 'tailwind-merge';
 
 export function cn(...inputs: ClassValue[]) {
@@ -61,49 +61,51 @@ export function normalizeString(input: string, spaceReplacer = ' '): string {
 
 /**
  * Removes any readonly custom fields from form values before submission.
+ * Also removes localeString and localeText fields from the root customFields object
+ * since they should only exist in the translations array.
  * This prevents errors when submitting readonly custom field values to mutations.
  *
  * @param values - The form values that may contain custom fields
  * @param customFieldConfigs - Array of custom field configurations for the entity
- * @returns The values with readonly custom fields removed
+ * @returns The values with readonly custom fields removed and locale fields properly placed
  */
-export function removeReadonlyCustomFields<T extends Record<string, any>>(
+export function removeReadonlyAndLocalizedCustomFields<T extends Record<string, any>>(
     values: T,
-    customFieldConfigs: Array<{ name: string; readonly?: boolean | null }> = [],
+    customFieldConfigs: Array<{ name: string; readonly?: boolean | null; type?: string }> = [],
 ): T {
     if (!values || !customFieldConfigs?.length) {
         return values;
     }
 
-    // Create a deep copy to avoid mutating the original values
     const result = structuredClone(values);
-
-    // Get readonly field names
     const readonlyFieldNames = customFieldConfigs
         .filter(config => config.readonly === true)
         .map(config => config.name);
+    const localeFieldNames = customFieldConfigs
+        .filter(config => config.type === 'localeString' || config.type === 'localeText')
+        .map(config => config.name);
+    const fieldsToRemoveFromRoot = [...readonlyFieldNames, ...localeFieldNames];
 
-    if (readonlyFieldNames.length === 0) {
-        return result;
-    }
-
-    // Remove readonly fields from main customFields
     if (result.customFields && typeof result.customFields === 'object') {
-        for (const fieldName of readonlyFieldNames) {
+        fieldsToRemoveFromRoot.forEach(fieldName => {
             delete result.customFields[fieldName];
-        }
+        });
     }
 
-    // Remove readonly fields from translations customFields
-    if (Array.isArray(result.translations)) {
-        for (const translation of result.translations) {
-            if (translation?.customFields && typeof translation.customFields === 'object') {
-                for (const fieldName of readonlyFieldNames) {
-                    delete translation.customFields[fieldName];
-                }
-            }
-        }
+    removeReadonlyFromTranslations(result, readonlyFieldNames);
+    return result;
+}
+
+function removeReadonlyFromTranslations(entity: Record<string, any>, readonlyFieldNames: string[]): void {
+    if (!Array.isArray(entity.translations)) {
+        return;
     }
 
-    return result;
+    entity.translations.forEach(translation => {
+        if (translation?.customFields && typeof translation.customFields === 'object') {
+            readonlyFieldNames.forEach(fieldName => {
+                delete translation.customFields[fieldName];
+            });
+        }
+    });
 }