Browse Source

refactor(dashboard): Simplify form handling (#3748)

Michael Bromley 4 months ago
parent
commit
29aa8671c7
80 changed files with 2485 additions and 3690 deletions
  1. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/color-picker.webp
  2. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/dev-mode.webp
  3. 0 608
      docs/docs/guides/extending-the-dashboard/custom-form-components/display-components.md
  4. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/example-currency-input.webp
  5. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/example-email-input.webp
  6. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/example-slug-input.webp
  7. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/example-tags-input.webp
  8. 445 0
      docs/docs/guides/extending-the-dashboard/custom-form-components/form-component-examples.mdx
  9. 195 493
      docs/docs/guides/extending-the-dashboard/custom-form-components/index.md
  10. 0 482
      docs/docs/guides/extending-the-dashboard/custom-form-components/input-components.md
  11. BIN
      docs/docs/guides/extending-the-dashboard/custom-form-components/locator.webp
  12. 1 1
      docs/docs/reference/core-plugins/admin-ui-plugin/admin-ui-plugin-options.md
  13. 1 1
      docs/docs/reference/core-plugins/admin-ui-plugin/index.md
  14. 4 48
      docs/docs/reference/dashboard/extensions/detail-forms.md
  15. 4 4
      docs/docs/reference/dashboard/extensions/form-components.md
  16. 135 0
      docs/docs/reference/dashboard/forms/dashboard-form-component.md
  17. 14 0
      docs/docs/reference/dashboard/forms/index.md
  18. 1 2
      docs/sidebars.js
  19. 2 1
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-modification-preview-dialog.tsx
  20. 1 0
      packages/dashboard/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx
  21. 19 5
      packages/dashboard/src/lib/components/data-input/affixed-input.tsx
  22. 9 0
      packages/dashboard/src/lib/components/data-input/boolean-input.tsx
  23. 8 0
      packages/dashboard/src/lib/components/data-input/checkbox-input.tsx
  24. 11 2
      packages/dashboard/src/lib/components/data-input/combination-mode-input.tsx
  25. 26 401
      packages/dashboard/src/lib/components/data-input/configurable-operation-list-input.tsx
  26. 18 25
      packages/dashboard/src/lib/components/data-input/custom-field-list-input.tsx
  27. 7 11
      packages/dashboard/src/lib/components/data-input/customer-group-input.tsx
  28. 9 13
      packages/dashboard/src/lib/components/data-input/datetime-input.tsx
  29. 29 9
      packages/dashboard/src/lib/components/data-input/default-relation-input.tsx
  30. 15 13
      packages/dashboard/src/lib/components/data-input/facet-value-input.tsx
  31. 2 2
      packages/dashboard/src/lib/components/data-input/index.ts
  32. 27 20
      packages/dashboard/src/lib/components/data-input/money-input.tsx
  33. 48 0
      packages/dashboard/src/lib/components/data-input/number-input.tsx
  34. 16 0
      packages/dashboard/src/lib/components/data-input/password-input.tsx
  35. 8 15
      packages/dashboard/src/lib/components/data-input/product-multi-selector-input.tsx
  36. 7 6
      packages/dashboard/src/lib/components/data-input/relation-input.tsx
  37. 10 13
      packages/dashboard/src/lib/components/data-input/rich-text-input.tsx
  38. 29 17
      packages/dashboard/src/lib/components/data-input/select-with-options.tsx
  39. 54 59
      packages/dashboard/src/lib/components/data-input/struct-form-input.tsx
  40. 9 0
      packages/dashboard/src/lib/components/data-input/text-input.tsx
  41. 16 0
      packages/dashboard/src/lib/components/data-input/textarea-input.tsx
  42. 3 0
      packages/dashboard/src/lib/components/data-table/filters/data-table-number-filter.tsx
  43. 16 5
      packages/dashboard/src/lib/components/data-table/use-generated-columns.tsx
  44. 3 10
      packages/dashboard/src/lib/components/shared/configurable-operation-arg-input.tsx
  45. 1 6
      packages/dashboard/src/lib/components/shared/configurable-operation-input.tsx
  46. 8 5
      packages/dashboard/src/lib/components/shared/configurable-operation-multi-selector.tsx
  47. 5 5
      packages/dashboard/src/lib/components/shared/configurable-operation-selector.tsx
  48. 20 49
      packages/dashboard/src/lib/components/shared/custom-fields-form.tsx
  49. 0 393
      packages/dashboard/src/lib/components/shared/direct-form-component-map.tsx
  50. 1 1
      packages/dashboard/src/lib/components/shared/multi-select.tsx
  51. 0 118
      packages/dashboard/src/lib/components/shared/universal-field-definition.ts
  52. 0 175
      packages/dashboard/src/lib/components/shared/universal-form-input.tsx
  53. 0 291
      packages/dashboard/src/lib/components/shared/universal-input-components.tsx
  54. 9 32
      packages/dashboard/src/lib/framework/component-registry/component-registry.tsx
  55. 28 0
      packages/dashboard/src/lib/framework/component-registry/display-component.tsx
  56. 0 58
      packages/dashboard/src/lib/framework/component-registry/dynamic-component.tsx
  57. 0 14
      packages/dashboard/src/lib/framework/extension-api/display-component-extensions.tsx
  58. 52 34
      packages/dashboard/src/lib/framework/extension-api/input-component-extensions.tsx
  59. 4 27
      packages/dashboard/src/lib/framework/extension-api/logic/data-table.ts
  60. 3 2
      packages/dashboard/src/lib/framework/extension-api/logic/form-components.ts
  61. 2 38
      packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts
  62. 2 4
      packages/dashboard/src/lib/framework/extension-api/types/form-components.ts
  63. 0 23
      packages/dashboard/src/lib/framework/form-engine/custom-form-component-extensions.ts
  64. 8 25
      packages/dashboard/src/lib/framework/form-engine/custom-form-component.tsx
  65. 35 0
      packages/dashboard/src/lib/framework/form-engine/default-input-for-type.tsx
  66. 192 0
      packages/dashboard/src/lib/framework/form-engine/form-control-adapter.tsx
  67. 163 0
      packages/dashboard/src/lib/framework/form-engine/form-engine-types.ts
  68. 55 71
      packages/dashboard/src/lib/framework/form-engine/form-schema-tools.ts
  69. 2 2
      packages/dashboard/src/lib/framework/form-engine/overridden-form-component.tsx
  70. 223 0
      packages/dashboard/src/lib/framework/form-engine/utils.ts
  71. 9 9
      packages/dashboard/src/lib/framework/form-engine/value-transformers.ts
  72. 3 5
      packages/dashboard/src/lib/framework/registry/registry-types.ts
  73. 0 1
      packages/dashboard/src/lib/graphql/graphql-env.d.ts
  74. 28 1
      packages/dashboard/src/lib/index.ts
  75. 1 0
      packages/dashboard/src/lib/providers/server-config.tsx
  76. 295 0
      packages/dev-server/test-plugins/field-test/dashboard/form-components.tsx
  77. 73 0
      packages/dev-server/test-plugins/field-test/dashboard/index.tsx
  78. 45 3
      packages/dev-server/test-plugins/field-test/field-test-plugin.ts
  79. 16 30
      packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx
  80. 0 2
      packages/dev-server/test-plugins/reviews/dashboard/index.tsx

BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/color-picker.webp


BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/dev-mode.webp


+ 0 - 608
docs/docs/guides/extending-the-dashboard/custom-form-components/display-components.md

@@ -1,608 +0,0 @@
----
-title: 'Display Components'
----
-
-Display components allow you to customize how data is rendered in forms, tables, detail views, and other places in the dashboard. They provide a way to create rich visualizations and presentations of your data beyond the standard text rendering.
-
-## How Display Components Work
-
-Display components are targeted to specific locations in the dashboard using three identifiers:
-
-- **pageId**: The page where the component should appear (e.g., 'product-detail', 'order-list')
-- **blockId**: The block within that page (e.g., 'product-form', 'order-table')
-- **field**: The specific field to customize (e.g., 'status', 'price', 'createdAt')
-
-When the dashboard renders a field that matches these criteria, your custom display component will be used instead of the default rendering.
-
-## Registration Method
-
-Display components are registered by co-locating them with detail form definitions. This approach is consistent and avoids repeating the `pageId`. You can also include input components in the same definition:
-
-```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
-import { defineDashboardExtension } from '@vendure/dashboard';
-import { StatusBadgeComponent, PriceDisplayComponent, MyPriceInput } from './components';
-
-export default defineDashboardExtension({
-    detailForms: [
-        {
-            pageId: 'product-detail',
-            displays: [
-                {
-                    blockId: 'main-form',
-                    field: 'status',
-                    component: StatusBadgeComponent,
-                },
-                {
-                    blockId: 'main-form',
-                    field: 'price',
-                    component: PriceDisplayComponent,
-                },
-            ],
-            inputs: [
-                {
-                    blockId: 'main-form',
-                    field: 'price',
-                    component: MyPriceInput,
-                },
-            ],
-        },
-        {
-            pageId: 'order-detail',
-            displays: [
-                {
-                    blockId: 'order-summary',
-                    field: 'status',
-                    component: StatusBadgeComponent,
-                },
-            ],
-        },
-    ],
-});
-```
-
-## Basic Display Component
-
-Display components receive the field value and additional context properties:
-
-```tsx title="src/plugins/my-plugin/dashboard/components/status-badge.tsx"
-import { Badge } from '@vendure/dashboard';
-import { CheckCircle, Clock, XCircle, AlertCircle } from 'lucide-react';
-
-interface StatusBadgeProps {
-    value: string;
-}
-
-export function StatusBadgeComponent({ value }: DataDisplayComponentProps) {
-    const getStatusConfig = (status: string) => {
-        switch (status?.toLowerCase()) {
-            case 'active':
-            case 'approved':
-            case 'completed':
-                return {
-                    variant: 'default' as const,
-                    icon: CheckCircle,
-                    className: 'bg-green-100 text-green-800 border-green-200',
-                };
-            case 'pending':
-            case 'processing':
-                return {
-                    variant: 'secondary' as const,
-                    icon: Clock,
-                    className: 'bg-yellow-100 text-yellow-800 border-yellow-200',
-                };
-            case 'cancelled':
-            case 'rejected':
-                return {
-                    variant: 'destructive' as const,
-                    icon: XCircle,
-                    className: 'bg-red-100 text-red-800 border-red-200',
-                };
-            default:
-                return {
-                    variant: 'outline' as const,
-                    icon: AlertCircle,
-                    className: 'bg-gray-100 text-gray-800 border-gray-200',
-                };
-        }
-    };
-
-    const config = getStatusConfig(value);
-    const Icon = config.icon;
-
-    return (
-        <Badge variant={config.variant} className={`flex items-center gap-1 ${config.className}`}>
-            <Icon className="h-3 w-3" />
-            {value || 'Unknown'}
-        </Badge>
-    );
-}
-```
-
-## Registration and Targeting
-
-Register your display component and specify where it should be used:
-
-```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
-import { defineDashboardExtension } from '@vendure/dashboard';
-import { StatusBadgeComponent } from './components/status-badge';
-import { PriceDisplayComponent } from './components/price-display';
-import { DateTimeDisplayComponent } from './components/datetime-display';
-
-export default defineDashboardExtension({
-    customFormComponents: {
-        displays: [
-            {
-                pageId: 'order-detail',
-                blockId: 'order-summary',
-                field: 'state',
-                component: StatusBadgeComponent,
-            },
-            {
-                pageId: 'product-list',
-                blockId: 'product-table',
-                field: 'price',
-                component: PriceDisplayComponent,
-            },
-            {
-                pageId: 'order-list',
-                blockId: 'order-table',
-                field: 'orderPlacedAt',
-                component: DateTimeDisplayComponent,
-            },
-        ],
-    },
-});
-```
-
-## Advanced Examples
-
-### Enhanced Price Display
-
-```tsx title="src/plugins/my-plugin/dashboard/components/price-display.tsx"
-import { Badge, DataDisplayComponentProps } from '@vendure/dashboard';
-import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
-
-interface PriceDisplayProps extends DataDisplayComponentProps {
-    // Additional context that might be passed
-    currency?: string;
-    originalPrice?: number;
-    comparisonPrice?: number;
-}
-
-export function PriceDisplayComponent({
-    value,
-    currency = 'USD',
-    originalPrice,
-    comparisonPrice,
-}: PriceDisplayProps) {
-    const formatPrice = (price: number) => {
-        return new Intl.NumberFormat('en-US', {
-            style: 'currency',
-            currency: currency,
-        }).format(price / 100); // Assuming prices are stored in cents
-    };
-
-    const getDiscountInfo = () => {
-        if (!originalPrice || originalPrice <= value) return null;
-
-        const discountPercent = Math.round(((originalPrice - value) / originalPrice) * 100);
-        return {
-            percent: discountPercent,
-            amount: originalPrice - value,
-        };
-    };
-
-    const getTrendInfo = () => {
-        if (!comparisonPrice) return null;
-
-        const change = value - comparisonPrice;
-        const changePercent = Math.round((change / comparisonPrice) * 100);
-
-        return {
-            change,
-            changePercent,
-            trend: change > 0 ? 'up' : change < 0 ? 'down' : 'same',
-        };
-    };
-
-    const discount = getDiscountInfo();
-    const trend = getTrendInfo();
-
-    return (
-        <div className="flex items-center gap-2">
-            <span className="font-medium">{formatPrice(value)}</span>
-
-            {discount && (
-                <div className="flex items-center gap-1">
-                    <span className="text-sm text-muted-foreground line-through">
-                        {formatPrice(originalPrice!)}
-                    </span>
-                    <Badge variant="destructive" className="text-xs">
-                        -{discount.percent}%
-                    </Badge>
-                </div>
-            )}
-
-            {trend && trend.trend !== 'same' && (
-                <Badge
-                    variant={trend.trend === 'up' ? 'default' : 'secondary'}
-                    className={`flex items-center gap-1 text-xs ${
-                        trend.trend === 'up' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
-                    }`}
-                >
-                    {trend.trend === 'up' ? (
-                        <TrendingUp className="h-3 w-3" />
-                    ) : (
-                        <TrendingDown className="h-3 w-3" />
-                    )}
-                    {Math.abs(trend.changePercent)}%
-                </Badge>
-            )}
-        </div>
-    );
-}
-```
-
-### Rich Date/Time Display
-
-```tsx title="src/plugins/my-plugin/dashboard/components/datetime-display.tsx"
-import { Badge, DataDisplayComponentProps } from '@vendure/dashboard';
-import { Calendar, Clock, Users } from 'lucide-react';
-import { formatDistanceToNow, format, isToday, isYesterday } from 'date-fns';
-
-interface DateTimeDisplayProps extends DataDisplayComponentProps {
-    showRelative?: boolean;
-    showTime?: boolean;
-    showTimezone?: boolean;
-}
-
-export function DateTimeDisplayComponent({
-    value,
-    showRelative = true,
-    showTime = true,
-    showTimezone = false,
-}: DateTimeDisplayProps) {
-    if (!value) return <span className="text-muted-foreground">-</span>;
-
-    const date = value instanceof Date ? value : new Date(value);
-
-    // Handle invalid dates
-    if (isNaN(date.getTime())) {
-        return <span className="text-destructive">Invalid date</span>;
-    }
-
-    const formatAbsolute = () => {
-        if (showTime) {
-            return format(date, showTimezone ? 'MMM d, yyyy HH:mm zzz' : 'MMM d, yyyy HH:mm');
-        }
-        return format(date, 'MMM d, yyyy');
-    };
-
-    const formatRelative = () => {
-        if (isToday(date)) {
-            return `Today at ${format(date, 'HH:mm')}`;
-        }
-        if (isYesterday(date)) {
-            return `Yesterday at ${format(date, 'HH:mm')}`;
-        }
-        return formatDistanceToNow(date, { addSuffix: true });
-    };
-
-    const getDateBadge = () => {
-        const now = new Date();
-        const diffHours = Math.abs(now.getTime() - date.getTime()) / (1000 * 60 * 60);
-
-        if (diffHours < 1) {
-            return { label: 'Just now', variant: 'default' as const, icon: Clock };
-        }
-        if (diffHours < 24) {
-            return { label: 'Recent', variant: 'secondary' as const, icon: Clock };
-        }
-        if (diffHours < 168) {
-            // 1 week
-            return { label: 'This week', variant: 'outline' as const, icon: Calendar };
-        }
-        return null;
-    };
-
-    const badge = getDateBadge();
-
-    return (
-        <div className="flex items-center gap-2">
-            <div className="flex flex-col">
-                <span className="text-sm font-medium">
-                    {showRelative ? formatRelative() : formatAbsolute()}
-                </span>
-                {showRelative && <span className="text-xs text-muted-foreground">{formatAbsolute()}</span>}
-            </div>
-
-            {badge && (
-                <Badge variant={badge.variant} className="flex items-center gap-1 text-xs">
-                    <badge.icon className="h-3 w-3" />
-                    {badge.label}
-                </Badge>
-            )}
-        </div>
-    );
-}
-```
-
-### Image/Avatar Display
-
-```tsx title="src/plugins/my-plugin/dashboard/components/avatar-display.tsx"
-import { Avatar, AvatarFallback, AvatarImage, Badge, DataDisplayComponentProps } from '@vendure/dashboard';
-import { User, Users, Building } from 'lucide-react';
-
-interface AvatarDisplayProps extends DataDisplayComponentProps {
-    name?: string;
-    type?: 'user' | 'customer' | 'admin' | 'system';
-    size?: 'sm' | 'md' | 'lg';
-    showStatus?: boolean;
-    isOnline?: boolean;
-}
-
-export function AvatarDisplayComponent({
-    value,
-    name,
-    type = 'user',
-    size = 'md',
-    showStatus = false,
-    isOnline = false,
-}: AvatarDisplayProps) {
-    const getInitials = (name?: string) => {
-        if (!name) return '?';
-        return name
-            .split(' ')
-            .map(word => word[0])
-            .join('')
-            .toUpperCase()
-            .slice(0, 2);
-    };
-
-    const getSizeClasses = () => {
-        switch (size) {
-            case 'sm':
-                return 'h-6 w-6 text-xs';
-            case 'lg':
-                return 'h-12 w-12 text-lg';
-            default:
-                return 'h-8 w-8 text-sm';
-        }
-    };
-
-    const getTypeIcon = () => {
-        switch (type) {
-            case 'admin':
-                return Users;
-            case 'system':
-                return Building;
-            default:
-                return User;
-        }
-    };
-
-    const TypeIcon = getTypeIcon();
-
-    return (
-        <div className="flex items-center gap-2">
-            <div className="relative">
-                <Avatar className={getSizeClasses()}>
-                    <AvatarImage src={value} alt={name || 'Avatar'} />
-                    <AvatarFallback>
-                        {name ? getInitials(name) : <TypeIcon className="h-4 w-4" />}
-                    </AvatarFallback>
-                </Avatar>
-
-                {showStatus && (
-                    <div
-                        className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background ${
-                            isOnline ? 'bg-green-500' : 'bg-gray-400'
-                        }`}
-                    />
-                )}
-            </div>
-
-            {name && (
-                <div className="flex flex-col">
-                    <span className="text-sm font-medium">{name}</span>
-                    {type !== 'user' && (
-                        <Badge variant="outline" className="text-xs w-fit">
-                            {type}
-                        </Badge>
-                    )}
-                </div>
-            )}
-        </div>
-    );
-}
-```
-
-### Progress/Percentage Display
-
-```tsx title="src/plugins/my-plugin/dashboard/components/progress-display.tsx"
-import { Progress, Badge, DataDisplayComponentProps } from '@vendure/dashboard';
-import { CheckCircle, AlertCircle, Clock } from 'lucide-react';
-
-interface ProgressDisplayProps extends DataDisplayComponentProps {
-    total?: number;
-    current?: number;
-    label?: string;
-    showPercent?: boolean;
-}
-
-export function ProgressDisplayComponent({
-    value,
-    total,
-    current,
-    label,
-    showPercent = true,
-}: ProgressDisplayProps) {
-    const percentage = Math.max(0, Math.min(100, value));
-
-    const getStatusConfig = (percent: number) => {
-        if (percent >= 100) {
-            return { icon: CheckCircle, color: 'text-green-600', bgColor: 'bg-green-500' };
-        }
-        if (percent >= 75) {
-            return { icon: Clock, color: 'text-blue-600', bgColor: 'bg-blue-500' };
-        }
-        if (percent >= 25) {
-            return { icon: Clock, color: 'text-yellow-600', bgColor: 'bg-yellow-500' };
-        }
-        return { icon: AlertCircle, color: 'text-red-600', bgColor: 'bg-red-500' };
-    };
-
-    const status = getStatusConfig(percentage);
-    const Icon = status.icon;
-
-    return (
-        <div className="flex items-center gap-3 min-w-[200px]">
-            <div className="flex-1">
-                <div className="flex items-center justify-between mb-1">
-                    <div className="flex items-center gap-1">
-                        <Icon className={`h-3 w-3 ${status.color}`} />
-                        {label && <span className="text-xs text-muted-foreground">{label}</span>}
-                    </div>
-                    <div className="text-xs font-medium">
-                        {showPercent && `${Math.round(percentage)}%`}
-                        {total && current && ` (${current}/${total})`}
-                    </div>
-                </div>
-                <Progress value={percentage} className="h-2" />
-            </div>
-
-            {percentage >= 100 && (
-                <Badge variant="default" className="bg-green-100 text-green-800 text-xs">
-                    Complete
-                </Badge>
-            )}
-        </div>
-    );
-}
-```
-
-## Common Display Patterns
-
-### Table Display Components
-
-For data table contexts, keep components compact and scannable:
-
-```tsx
-// Good: Compact status indicator
-<Badge variant="outline" className="text-xs">Active</Badge>
-
-// Good: Abbreviated date
-<span className="text-xs text-muted-foreground">
-    {format(date, 'MMM d')}
-</span>
-
-// Avoid: Large, complex components in table cells
-```
-
-### Detail View Components
-
-For detail pages, you can use richer, more informative displays:
-
-```tsx
-// Good: Rich information display
-<div className="space-y-2">
-    <div className="flex items-center gap-2">
-        <StatusIcon />
-        <span className="font-medium">{status}</span>
-        <Badge>{category}</Badge>
-    </div>
-    <p className="text-sm text-muted-foreground">{description}</p>
-</div>
-```
-
-### List Item Components
-
-For list contexts, balance information density with readability:
-
-```tsx
-// Good: Inline information with clear hierarchy
-<div className="flex items-center justify-between">
-    <div className="flex items-center gap-2">
-        <Avatar size="sm" />
-        <span>{name}</span>
-    </div>
-    <Badge variant="outline">{status}</Badge>
-</div>
-```
-
-## Component Props
-
-Display components receive these standard props through the `DataDisplayComponentProps` interface:
-
-```tsx
-import { DataDisplayComponentProps } from '@vendure/dashboard';
-
-// The DataDisplayComponentProps interface provides:
-interface DataDisplayComponentProps {
-    value: any; // The value to display
-    [key: string]: any; // Additional props that may be passed
-}
-
-// Common additional props that may be available:
-// - fieldName?: string         // The name of the field
-// - entityType?: string        // Type of entity being displayed
-// - entityId?: string          // ID of the entity
-// - compact?: boolean          // Whether to show compact version
-// - interactive?: boolean      // Whether component should be interactive
-// - metadata?: Record<string, any> // Additional data for complex displays
-```
-
-## Best Practices
-
-1. **Keep it readable**: Display components should enhance readability, not complicate it
-2. **Use appropriate sizing**: Match the context (table cell vs detail view vs list item)
-3. **Handle null/undefined values**: Always provide fallbacks for missing data
-4. **Use dashboard design tokens**: Stick to the established color palette and spacing
-5. **Consider loading states**: Show skeletons or placeholders when data is loading
-6. **Make it accessible**: Use proper ARIA labels and semantic HTML
-7. **Optimize for scanning**: In table contexts, make information quickly scannable
-
-## Finding Display Contexts
-
-Common contexts where display components are used:
-
-### Data Tables
-
-```tsx
-pageId: 'product-list';
-blockId: 'product-table';
-// Fields: name, sku, price, stock, status, createdAt
-```
-
-### Detail Views
-
-```tsx
-pageId: 'order-detail';
-blockId: 'order-summary';
-// Fields: code, state, total, customer, orderPlacedAt
-```
-
-### List Components
-
-```tsx
-pageId: 'customer-list';
-blockId: 'customer-list';
-// Fields: name, email, totalOrders, lastOrderDate
-```
-
-:::tip Performance
-Display components may be rendered many times in table contexts. Keep them lightweight and avoid expensive calculations or API calls in the render function.
-:::
-
-:::note Interactivity
-Display components are primarily for data visualization. If you need interactive elements, consider whether an input component or action bar item might be more appropriate.
-:::
-
-:::warning Context Awareness
-Display components should adapt to their context. A component used in a table should be more compact than the same component used in a detail view.
-:::
-
-## Related Guides
-
-- **[Custom Form Elements Overview](./)** - Learn about the unified system for custom field components, input components, and display components
-- **[Input Components](./input-components)** - Create custom input controls for forms with specialized functionality

BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/example-currency-input.webp


BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/example-email-input.webp


BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/example-slug-input.webp


BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/example-tags-input.webp


+ 445 - 0
docs/docs/guides/extending-the-dashboard/custom-form-components/form-component-examples.mdx

@@ -0,0 +1,445 @@
+---
+title: 'Form Component Examples'
+---
+
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## Email Input with Validation
+
+This example uses the `react-hook-form` validation state in order to display an icon indicating
+the validity of the email address, as defined by the custom field "pattern" option:
+
+![Email input](./example-email-input.webp)
+
+
+<Tabs>
+<TabItem value="Component" label="Component" default>
+    ```tsx title="src/plugins/my-plugin/dashboard/components/email-input.tsx"
+    import {AffixedInput, DashboardFormComponent} from '@vendure/dashboard';
+    import {Mail, Check, X} from 'lucide-react';
+    import {useFormContext} from 'react-hook-form';
+
+    export const EmailInputComponent: DashboardFormComponent = ({name, value, onChange, disabled}) => {
+        // highlight-start
+        const {getFieldState} = useFormContext();
+        const isValid = getFieldState(name).invalid === false;
+        // highlight-end
+
+        return (
+            <AffixedInput
+                prefix={<Mail className="h-4 w-4 text-muted-foreground" />}
+                suffix={
+                    value &&
+                    // highlight-start
+                    (isValid ? (
+                        <Check className="h-4 w-4 text-green-500" />
+                    ) : (
+                        <X className="h-4 w-4 text-red-500" />
+                    ))
+                    // highlight-end
+                }
+                value={value || ''}
+                onChange={e => onChange(e.target.value)}
+                disabled={disabled}
+                placeholder="Enter email address"
+                className="pl-10 pr-10"
+                name={name}
+            />
+        );
+    };
+    ```
+</TabItem>
+<TabItem value="Registration" label="Registration">
+    ```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+    import { defineDashboardExtension } from '@vendure/dashboard';
+
+    import { EmailInputComponent } from './components/email-input';
+
+    defineDashboardExtension({
+        customFormComponents: {
+            customFields: [
+                {
+                    // highlight-start
+                    id: 'custom-email',
+                    component: EmailInputComponent,
+                    // highlight-end
+                },
+            ],
+        }
+    });
+    ```
+</TabItem>
+<TabItem value="Custom field definition" label="Custom field definition">
+    ```ts title="src/plugins/my-plugin/my.plugin.ts"
+    @VendureConfig({
+        configuration: config => {
+            config.customFields.Seller.push({
+                name: 'supplierEmail',
+                type: 'string',
+                label: [{ languageCode: LanguageCode.en, value: 'Supplier Email' }],
+                // highlight-start
+                pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$',
+                ui: { component: 'custom-email' },
+                // highlight-end
+            });
+            return config;
+        }
+    })
+    export class MyPlugin {}
+    ```
+</TabItem>
+</Tabs>
+
+## Multi-Currency Price Input
+
+This example demonstrates a component with its own state (using `useState`) and more complex
+internal logic.
+
+![Currency input](./example-currency-input.webp)
+
+<Tabs>
+<TabItem value="Component" label="Component" default>
+    ```tsx title="src/plugins/my-plugin/dashboard/components/price-input.tsx"
+    import {
+        AffixedInput,
+        DashboardFormComponent,
+        Select,
+        SelectContent,
+        SelectItem,
+        SelectTrigger,
+        SelectValue,
+        useLocalFormat,
+    } from '@vendure/dashboard';
+    import { useState } from 'react';
+
+    export const MultiCurrencyInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
+        const [currency, setCurrency] = useState('USD');
+        const { formatCurrencyName } = useLocalFormat();
+
+        const currencies = [
+            { code: 'USD', symbol: '$', rate: 1 },
+            { code: 'EUR', symbol: '€', rate: 0.85 },
+            { code: 'GBP', symbol: '£', rate: 0.73 },
+            { code: 'JPY', symbol: '¥', rate: 110 },
+        ];
+
+        const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];
+
+        // Convert price based on exchange rate
+        const displayValue = value ? (value * selectedCurrency.rate).toFixed(2) : '';
+
+        const handleChange = (val: string) => {
+            const numericValue = parseFloat(val) || 0;
+            // Convert back to base currency (USD) for storage
+            const baseValue = numericValue / selectedCurrency.rate;
+            onChange(baseValue);
+        };
+
+        return (
+            <div className="flex space-x-2">
+                <Select value={currency} onValueChange={setCurrency} disabled={disabled}>
+                    <SelectTrigger className="w-24">
+                        <SelectValue>
+                            <div className="flex items-center gap-1">{currency}</div>
+                        </SelectValue>
+                    </SelectTrigger>
+                    <SelectContent>
+                        {currencies.map(curr => {
+                            return (
+                                <SelectItem key={curr.code} value={curr.code}>
+                                    <div className="flex items-center gap-2">{formatCurrencyName(curr.code)}</div>
+                                </SelectItem>
+                            );
+                        })}
+                    </SelectContent>
+                </Select>
+                <AffixedInput
+                    prefix={selectedCurrency.symbol}
+                    value={displayValue}
+                    onChange={e => onChange(e.target.value)}
+                    disabled={disabled}
+                    placeholder="0.00"
+                    name={name}
+                />
+            </div>
+        );
+    };
+    ```
+</TabItem>
+<TabItem value="Registration" label="Registration">
+    ```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+    import { defineDashboardExtension } from '@vendure/dashboard';
+
+    import { MultiCurrencyInputComponent } from './components/price-input';
+
+    defineDashboardExtension({
+        customFormComponents: {
+            customFields: [
+                {
+                    id: 'custom-price',
+                    component: MultiCurrencyInputComponent,
+                },
+            ],
+        }
+    });
+    ```
+</TabItem>
+<TabItem value="Custom field definition" label="Custom field definition">
+    ```ts title="src/plugins/my-plugin/my.plugin.ts"
+    @VendureConfig({
+        configuration: config => {
+            config.customFields.Product.push({
+                name: 'rrp',
+                type: 'int',
+                label: [{ languageCode: LanguageCode.en, value: 'RRP' }],
+                // highlight-start
+                ui: { component: 'custom-price' },
+                // highlight-end
+            });
+            return config;
+        }
+    })
+    export class MyPlugin {}
+    ```
+</TabItem>
+</Tabs>
+
+## Tags Input Component
+
+This component brings better UX to a simple comma-separated tags custom field.
+
+![Tags input](./example-tags-input.webp)
+
+
+<Tabs>
+<TabItem value="Component" label="Component" default>
+    ```tsx title="src/plugins/my-plugin/dashboard/components/tags-input.tsx"
+    import { Input, Badge, Button, DashboardFormComponent } from '@vendure/dashboard';
+    import { useState, KeyboardEvent } from 'react';
+    import { X } from 'lucide-react';
+
+    export const TagsInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name, onBlur }) => {
+        const [inputValue, setInputValue] = useState('');
+
+        // Parse tags from string value (comma-separated)
+        const tags: string[] = value ? value.split(',').filter(Boolean) : [];
+
+        const addTag = (tag: string) => {
+            const trimmedTag = tag.trim();
+            if (trimmedTag && !tags.includes(trimmedTag)) {
+                const newTags = [...tags, trimmedTag];
+                onChange(newTags.join(','));
+            }
+            setInputValue('');
+        };
+
+        const removeTag = (tagToRemove: string) => {
+            const newTags = tags.filter(tag => tag !== tagToRemove);
+            onChange(newTags.join(','));
+        };
+
+        const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
+            if (e.key === 'Enter' || e.key === ',') {
+                e.preventDefault();
+                addTag(inputValue);
+            } else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0) {
+                removeTag(tags[tags.length - 1]);
+            }
+        };
+
+        return (
+            <div className="space-y-2">
+                {/* Tags Display */}
+                <div className="flex flex-wrap gap-1">
+                    {tags.map((tag, index) => (
+                        <Badge key={index} variant="secondary" className="gap-1">
+                            {tag}
+                            <Button
+                                type="button"
+                                variant="ghost"
+                                size="icon"
+                                className="h-4 w-4 p-0 hover:bg-transparent"
+                                onClick={() => removeTag(tag)}
+                                disabled={disabled}
+                            >
+                                <X className="h-3 w-3" />
+                            </Button>
+                        </Badge>
+                    ))}
+                </div>
+
+                {/* Input */}
+                <Input
+                    value={inputValue}
+                    onChange={e => setInputValue(e.target.value)}
+                    onKeyDown={handleKeyDown}
+                    onBlur={onBlur}
+                    disabled={disabled}
+                    placeholder="Type a tag and press Enter or comma"
+                    name={name}
+                />
+            </div>
+        );
+    };
+    ```
+</TabItem>
+<TabItem value="Registration" label="Registration">
+    ```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+    import { defineDashboardExtension } from '@vendure/dashboard';
+
+    import { TagsInputComponent } from './components/tags-input';
+
+    defineDashboardExtension({
+        customFormComponents: {
+            customFields: [
+                {
+                    id: 'custom-tags',
+                    component: TagsInputComponent,
+                },
+            ],
+        }
+    });
+    ```
+</TabItem>
+<TabItem value="Custom field definition" label="Custom field definition">
+    ```ts title="src/plugins/my-plugin/my.plugin.ts"
+    @VendureConfig({
+        configuration: config => {
+            config.customFields.Product.push({
+                name: 'tags',
+                type: 'string',
+                label: [{ languageCode: LanguageCode.en, value: 'Tags' }],
+                // highlight-start
+                ui: { component: 'custom-tags' },
+                // highlight-end
+            });
+            return config;
+        }
+    })
+    export class MyPlugin {}
+    ```
+</TabItem>
+</Tabs>
+
+## Auto-generating Slug Input
+
+This example demonstrates a component that automatically generates a slug from the product name.
+It uses the `react-hook-form` `watch` method to get the value of another field in the form and 
+react to changes in that field.
+
+![Slug input](./example-slug-input.webp)
+
+<Tabs>
+<TabItem value="Component" label="Component" default>
+```tsx title="src/plugins/my-plugin/dashboard/components/slug-input.tsx"
+    import { Input, Button, Switch, DashboardFormComponent } from '@vendure/dashboard';
+    import { useFormContext } from 'react-hook-form';
+    import { useState, useEffect } from 'react';
+    import { RefreshCw, Lock, Unlock } from 'lucide-react';
+
+    export const SlugInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
+        const [autoGenerate, setAutoGenerate] = useState(!value);
+        const [isGenerating, setIsGenerating] = useState(false);
+        const { watch } = useFormContext();
+        const nameValue = watch('translations.0.name');
+
+        const generateSlug = (text: string) => {
+            return text
+                .toLowerCase()
+                .replace(/[^a-z0-9 -]/g, '') // Remove special characters
+                .replace(/\s+/g, '-') // Replace spaces with hyphens
+                .replace(/-+/g, '-') // Replace multiple hyphens with single
+                .trim('-'); // Remove leading/trailing hyphens
+        };
+
+        useEffect(() => {
+            if (autoGenerate && nameValue) {
+                const newSlug = generateSlug(nameValue);
+                if (newSlug !== value) {
+                    onChange(newSlug);
+                }
+            }
+        }, [nameValue, autoGenerate, onChange, value]);
+
+        const handleManualGenerate = async () => {
+            if (!nameValue) return;
+
+            setIsGenerating(true);
+            // Simulate API call for slug validation/generation
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+            const newSlug = generateSlug(nameValue);
+            onChange(newSlug);
+            setIsGenerating(false);
+        };
+
+        return (
+            <div className="space-y-2">
+                <div className="flex items-center space-x-2">
+                    <Input
+                        value={value || ''}
+                        onChange={e => onChange(e.target.value)}
+                        disabled={disabled || autoGenerate}
+                        placeholder="product-slug"
+                        className="flex-1"
+                        name={name}
+                    />
+
+                    <Button
+                        type="button"
+                        variant="outline"
+                        size="icon"
+                        disabled={disabled || !nameValue || isGenerating}
+                        onClick={handleManualGenerate}
+                    >
+                        <RefreshCw className={`h-4 w-4 ${isGenerating ? 'animate-spin' : ''}`} />
+                    </Button>
+                </div>
+
+                <div className="flex items-center space-x-2">
+                    <Switch checked={autoGenerate} onCheckedChange={setAutoGenerate} disabled={disabled} />
+                    <div className="flex items-center space-x-1 text-sm text-muted-foreground">
+                        {autoGenerate ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
+                        <span>Auto-generate from name</span>
+                    </div>
+                </div>
+            </div>
+        );
+    };
+    ```
+</TabItem>
+<TabItem value="Registration" label="Registration">
+    ```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+    import { defineDashboardExtension } from '@vendure/dashboard';
+
+    import { SlugInputComponent } from './components/slug-input';
+
+    defineDashboardExtension({
+        detailForms: [
+            {
+                // highlight-start
+                pageId: 'product-detail',
+                inputs: [
+                    {
+                        blockId: 'main-form',
+                        field: 'slug',
+                        component: SlugInputComponent,
+                    },
+                ],
+                // highlight-end
+            },
+        ]
+    });
+    ```
+</TabItem>
+</Tabs>
+
+:::note
+Input components completely replace the default input for the targeted field. Make sure your component handles all the data types and scenarios that the original input would have handled.
+:::
+
+## Related Guides
+
+- **[Custom Form Elements Overview](./)** - Learn about the unified system for custom field components, input components, and display components

+ 195 - 493
docs/docs/guides/extending-the-dashboard/custom-form-components/index.md

@@ -2,95 +2,41 @@
 title: 'Custom Form Elements'
 ---
 
-The dashboard allows you to create custom form elements that provide complete control over how data is rendered and how users interact with forms. This includes:
+The dashboard allows you to create custom form elements that provide complete control over how data is rendered and how users
+interact with forms. This includes:
 
-- **Custom Field Components** - For custom fields with specialized input requirements
-- **Input Components** - For targeted input controls in detail forms and other pages
-- **Display Components** - For custom data visualization and readonly displays
+- **Custom Field Components** - Globally-registered components that can be used to render **custom fields** and **configurable operation arguments**
+- **Detail Form Components** - Form input components that target specific fields of detail pages.
 
-:::important React Hook Form Integration
-Custom form components are heavily bound to the workflow of React Hook Form. The component props interface essentially passes React Hook Form render props (`field`, `fieldState`, `formState`) directly to your component, providing seamless integration with the form state management system.
-:::
-
-## Registration Approach
+## Anatomy of a Form Component
 
-Input and display components are registered by co-locating them with detail form definitions. This keeps everything better organized and avoids repeating the `pageId`:
-
-```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
-import { defineDashboardExtension } from '@vendure/dashboard';
-import { MyDescriptionInput, MyPriceInput, StatusBadgeDisplay } from './components';
-
-export default defineDashboardExtension({
-    detailForms: [
-        {
-            pageId: 'product-variant-detail',
-            inputs: [
-                {
-                    blockId: 'main-form',
-                    field: 'description',
-                    component: MyDescriptionInput,
-                },
-                {
-                    blockId: 'main-form',
-                    field: 'price',
-                    component: MyPriceInput,
-                },
-            ],
-            displays: [
-                {
-                    blockId: 'main-form',
-                    field: 'status',
-                    component: StatusBadgeDisplay,
-                },
-            ],
-        },
-    ],
-});
-```
-
-Custom field components continue to use the centralized registration approach:
-
-```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
-import { defineDashboardExtension } from '@vendure/dashboard';
-import { ColorPickerComponent } from './components/color-picker';
-import { RichTextEditorComponent } from './components/rich-text-editor';
-import { TagsInputComponent } from './components/tags-input';
-
-export default defineDashboardExtension({
-    customFormComponents: {
-        // Custom field components for custom fields
-        customFields: [
-            {
-                id: 'color-picker',
-                component: ColorPickerComponent,
-            },
-            {
-                id: 'rich-text-editor',
-                component: RichTextEditorComponent,
-            },
-            {
-                id: 'tags-input',
-                component: TagsInputComponent,
-            },
-        ],
-    },
-    // ... other extension properties
-});
-```
-
-## Custom Field Components
+All form components must implement the [DashboardFormComponent type](/reference/dashboard/forms/dashboard-form-component).
 
-Custom field components are React components used specifically for custom fields. They receive React Hook Form render props and use the dashboard's Shadcn UI design system.
+This type is based on the props that are made available from `react-hook-form`, which is the
+underlying form library used by the Dashboard.
 
-### Basic Custom Field Component
+Here's an example custom form component that has been annotated to explain the typical parts you will be working with:
 
 ```tsx title="src/plugins/my-plugin/dashboard/components/color-picker.tsx"
-import { CustomFormComponentInputProps, Input, Button, Card, CardContent } from '@vendure/dashboard';
+import { Button, Card, CardContent, cn, DashboardFormComponent, Input } from '@vendure/dashboard';
 import { useState } from 'react';
+import { useFormContext } from 'react-hook-form';
 
-export function ColorPickerComponent({ field, fieldState }: CustomFormComponentInputProps) {
+// By typing your component as DashboardFormComponent, the props will be correctly typed
+export const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => {
+    // You can use any of the built-in React hooks as usual
     const [isOpen, setIsOpen] = useState(false);
 
+    // To access the react-hook-form context, use this hook.
+    // This is useful for getting information about the current
+    // field, or even other fields in the form, which allows you
+    // to create advanced components that depend on the state of
+    // other fields in the form.
+    const { getFieldState } = useFormContext();
+    // The current field name is always passed in as a prop, allowing
+    // you to look up the field state
+    const error = getFieldState(name).error;
+
     const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD'];
 
     return (
@@ -100,15 +46,11 @@ export function ColorPickerComponent({ field, fieldState }: CustomFormComponentI
                     type="button"
                     variant="outline"
                     size="icon"
-                    className="w-8 h-8 border-2 border-gray-300 p-0"
-                    style={{ backgroundColor: field.value || '#ffffff' }}
+                    className={cn('w-8 h-8 border-2 border-gray-300 p-0', error && 'border-red-500')}
+                    style={{ backgroundColor: error ? 'transparent' : value || '#ffffff' }}
                     onClick={() => setIsOpen(!isOpen)}
                 />
-                <Input
-                    value={field.value || ''}
-                    onChange={e => field.onChange(e.target.value)}
-                    placeholder="#ffffff"
-                />
+                <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder="#ffffff" />
             </div>
 
             {isOpen && (
@@ -123,7 +65,7 @@ export function ColorPickerComponent({ field, fieldState }: CustomFormComponentI
                                 className="w-8 h-8 border-2 border-gray-300 hover:border-gray-500 p-0"
                                 style={{ backgroundColor: color }}
                                 onClick={() => {
-                                    field.onChange(color);
+                                    onChange(color);
                                     setIsOpen(false);
                                 }}
                             />
@@ -131,77 +73,144 @@ export function ColorPickerComponent({ field, fieldState }: CustomFormComponentI
                     </CardContent>
                 </Card>
             )}
-
-            {fieldState.error && <p className="text-sm text-destructive">{fieldState.error.message}</p>}
         </div>
     );
-}
+};
 ```
 
-## Input Components
+Here's how this component will look when rendered in your form:
 
-Input components allow you to replace specific input fields in existing dashboard forms with custom implementations. They are targeted to specific pages, blocks, and fields.
+![Color picker component](./color-picker.webp)
 
-### Basic Input Component
+## Custom Field Components
 
-```tsx title="src/plugins/my-plugin/dashboard/components/price-input.tsx"
-import { Input, Button } from '@vendure/dashboard';
-import { useState } from 'react';
-import { DollarSign, Euro, Pound } from 'lucide-react';
+Let's configure a [custom field](/guides/developer-guide/custom-fields/) which uses the `ColorPickerComponent` as its form component.
 
-interface PriceInputProps {
-    value: number;
-    onChange: (value: number) => void;
-    disabled?: boolean;
-}
+First we need to register the component with the `defineDashboardExtension` function:
 
-export function PriceInputComponent({ value, onChange, disabled }: PriceInputProps) {
-    const [currency, setCurrency] = useState('USD');
+```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+import { defineDashboardExtension } from '@vendure/dashboard';
+import { ColorPickerComponent } from './components/color-picker';
 
-    const currencies = [
-        { code: 'USD', symbol: '$', icon: DollarSign },
-        { code: 'EUR', symbol: '€', icon: Euro },
-        { code: 'GBP', symbol: '£', icon: Pound },
-    ];
+export default defineDashboardExtension({
+    customFormComponents: {
+        // Custom field components for custom fields
+        customFields: [
+            {
+                // highlight-start
+                // The "id" is a global identifier for this custom component. We will
+                // reference it in the next step.
+                id: 'color-picker',
+                component: ColorPickerComponent,
+                // highlight-end
+            },
+        ],
+    },
+    // ... other extension properties
+});
+```
 
-    const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];
-    const Icon = selectedCurrency.icon;
+Now that we've registered it as a custom field component, we can use it in our custom field definition.
+
+```tsx title="src/plugins/my-plugin/my.plugin.ts"
+@VendurePlugin({
+    // ...
+    configuration: config => {
+        config.customFields.Product.push({
+            name: 'color',
+            type: 'string',
+            pattern: '^#[A-Fa-f0-9]{6}$',
+            label: [{ languageCode: LanguageCode.en, value: 'Color' }],
+            description: [{ languageCode: LanguageCode.en, value: 'Main color for this product' }],
+            ui: {
+                // highlight-start
+                // This is the ID of the custom field
+                // component we registered above.
+                component: 'color-picker',
+                // highlight-end
+            },
+        });
+        return config;
+    },
+})
+export class MyPlugin {}
+```
 
+## Configurable Operation Components
+
+The `ColorPickerComponent` can also be used as a [configurable operation argument](/guides/developer-guide/strategies-configurable-operations/#configurable-operations) component. For example, we can add a color code
+to a shipping calculator:
+
+```tsx title="src/plugins/my-plugin/config/custom-shipping-calculator.ts"
+const customShippingCalculator = new ShippingCalculator({
+    code: 'custom-shipping-calculator',
+    description: [{ languageCode: LanguageCode.en, value: 'Custom Shipping Calculator' }],
+    args: {
+        color: {
+            type: 'string',
+            label: [{ languageCode: LanguageCode.en, value: 'Color' }],
+            description: [
+                { languageCode: LanguageCode.en, value: 'Color code for this shipping calculator' },
+            ],
+            // highlight-start
+            ui: { component: 'color-picker' },
+            // highlight-end
+        },
+    },
+    calculate: (ctx, order, args) => {
+        // ...
+    },
+});
+```
+
+## Detail Form Components
+
+Detail form components allow you to replace specific input fields in existing dashboard forms with custom implementations. They are targeted to specific pages, blocks, and fields.
+
+Let's say we want to use a plain text editor for the product description field rather than the default
+html-based editor.
+
+```tsx title="src/plugins/my-plugin/dashboard/components/markdown-editor.tsx"
+import { DashboardFormComponent, Textarea } from '@vendure/dashboard';
+
+// This is a simplified example - a real markdown editor should use
+// a library that handles markdown rendering and editing.
+export const MarkdownEditorComponent: DashboardFormComponent = props => {
     return (
-        <div className="flex items-center space-x-2">
-            <Button
-                type="button"
-                variant="outline"
-                size="sm"
-                disabled={disabled}
-                onClick={() => {
-                    const currentIndex = currencies.findIndex(c => c.code === currency);
-                    const nextIndex = (currentIndex + 1) % currencies.length;
-                    setCurrency(currencies[nextIndex].code);
-                }}
-                className="flex items-center gap-1"
-            >
-                <Icon className="h-4 w-4" />
-                {selectedCurrency.code}
-            </Button>
-
-            <div className="relative flex-1">
-                <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
-                    {selectedCurrency.symbol}
-                </span>
-                <Input
-                    type="number"
-                    value={value || ''}
-                    onChange={e => onChange(parseFloat(e.target.value) || 0)}
-                    disabled={disabled}
-                    className="pl-8"
-                    placeholder="0.00"
-                    step="0.01"
-                />
-            </div>
-        </div>
+        <Textarea
+            className="font-mono"
+            ref={props.ref}
+            onBlur={props.onBlur}
+            value={props.value}
+            onChange={e => props.onChange(e.target.value)}
+            disabled={props.disabled}
+        />
     );
-}
+};
+```
+
+You can then use this component in your detail form definition:
+
+```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+import { defineDashboardExtension } from '@vendure/dashboard';
+import { MarkdownEditorComponent } from './components/markdown-editor';
+
+export default defineDashboardExtension({
+    detailForms: [
+        {
+            // highlight-start
+            pageId: 'product-detail',
+            inputs: [
+                {
+                    blockId: 'main-form',
+                    field: 'description',
+                    component: MarkdownEditorComponent,
+                },
+            ],
+            // highlight-end
+        },
+    ],
+});
 ```
 
 ### Targeting Input Components
@@ -215,315 +224,62 @@ Input components are targeted using three properties:
 ```tsx
 inputs: [
     {
-        pageId: 'product-detail',
-        blockId: 'product-form',
+        pageId: 'product-variant-detail',
+        blockId: 'main-form',
         field: 'price',
         component: PriceInputComponent,
     },
     {
         pageId: 'customer-detail',
-        blockId: 'customer-info',
-        field: 'email',
+        blockId: 'main-form',
+        field: 'emailAddress',
         component: EmailInputComponent,
     },
 ];
 ```
 
-## Display Components
-
-Display components are used for readonly data visualization, replacing how specific data is displayed in forms, tables, and detail views.
-
-### Basic Display Component
-
-```tsx title="src/plugins/my-plugin/dashboard/components/status-badge.tsx"
-import { Badge } from '@vendure/dashboard';
-import { CheckCircle, Clock, XCircle, AlertCircle } from 'lucide-react';
-
-interface StatusBadgeProps {
-    value: string;
-}
-
-export function StatusBadgeComponent({ value }: StatusBadgeProps) {
-    const getStatusConfig = (status: string) => {
-        switch (status?.toLowerCase()) {
-            case 'active':
-            case 'approved':
-            case 'completed':
-                return {
-                    variant: 'default' as const,
-                    icon: CheckCircle,
-                    className: 'bg-green-100 text-green-800 border-green-200',
-                };
-            case 'pending':
-            case 'processing':
-                return {
-                    variant: 'secondary' as const,
-                    icon: Clock,
-                    className: 'bg-yellow-100 text-yellow-800 border-yellow-200',
-                };
-            case 'cancelled':
-            case 'rejected':
-                return {
-                    variant: 'destructive' as const,
-                    icon: XCircle,
-                    className: 'bg-red-100 text-red-800 border-red-200',
-                };
-            default:
-                return {
-                    variant: 'outline' as const,
-                    icon: AlertCircle,
-                    className: 'bg-gray-100 text-gray-800 border-gray-200',
-                };
-        }
-    };
-
-    const config = getStatusConfig(value);
-    const Icon = config.icon;
-
-    return (
-        <Badge variant={config.variant} className={`flex items-center gap-1 ${config.className}`}>
-            <Icon className="h-3 w-3" />
-            {value || 'Unknown'}
-        </Badge>
-    );
-}
-```
-
-### Targeting Display Components
-
-Display components use the same targeting system as input components:
-
-```tsx
-displays: [
-    {
-        pageId: 'order-detail',
-        blockId: 'order-summary',
-        field: 'status',
-        component: StatusBadgeComponent,
-    },
-    {
-        pageId: 'product-list',
-        blockId: 'product-table',
-        field: 'availability',
-        component: AvailabilityIndicatorComponent,
-    },
-];
-```
-
-## Component Props Interfaces
-
-### Custom Field Component Props
-
-Custom field components receive React Hook Form render props:
-
-```tsx
-interface CustomFormComponentInputProps {
-    // React Hook Form field controller (render prop)
-    field: {
-        name: string; // Field name
-        value: any; // Current field value
-        onChange: (value: any) => void; // Update field value
-        onBlur: () => void; // Handle field blur
-        ref: React.Ref; // Field reference
-    };
-
-    // React Hook Form field validation state (render prop)
-    fieldState: {
-        invalid: boolean; // Whether field has validation errors
-        isTouched: boolean; // Whether field has been interacted with
-        isDirty: boolean; // Whether field value has changed
-        error?: {
-            // Validation error details
-            type: string;
-            message: string;
-        };
-    };
-
-    // React Hook Form overall form state (render prop)
-    formState: {
-        isDirty: boolean; // Whether any form field has changed
-        isValid: boolean; // Whether form passes validation
-        isSubmitting: boolean; // Whether form is being submitted
-        // ... other React Hook Form state properties
-    };
-}
-```
-
-### Input Component Props
-
-Input components receive standard input props through the `DataInputComponentProps` interface:
-
-```tsx
-import { DataInputComponentProps } from '@vendure/dashboard';
-
-// The DataInputComponentProps interface provides:
-interface DataInputComponentProps {
-    value: any;
-    onChange: (value: any) => void;
-    [key: string]: any; // Additional props that may be passed
-}
-```
-
-### Display Component Props
-
-Display components receive the value and any additional context through the `DataDisplayComponentProps` interface:
-
-```tsx
-import { DataDisplayComponentProps } from '@vendure/dashboard';
-
-// The DataDisplayComponentProps interface provides:
-interface DataDisplayComponentProps {
-    value: any;
-    [key: string]: any; // Additional props that may be passed
-}
-```
-
-## Advanced Examples
-
-### Rich Text Editor Component
-
-```tsx title="src/plugins/my-plugin/dashboard/components/rich-text-editor.tsx"
-import { CustomFormComponentInputProps, Button, Card, CardContent } from '@vendure/dashboard';
-import { useState, useEffect } from 'react';
-import { Bold, Italic, Underline } from 'lucide-react';
-
-export function RichTextEditorComponent({ field, fieldState }: CustomFormComponentInputProps) {
-    const [editorContent, setEditorContent] = useState(field.value || '');
-
-    useEffect(() => {
-        setEditorContent(field.value || '');
-    }, [field.value]);
-
-    const handleContentChange = (content: string) => {
-        setEditorContent(content);
-        field.onChange(content);
-    };
-
-    const formatText = (command: string) => {
-        document.execCommand(command, false);
-    };
-
-    return (
-        <div className="space-y-2">
-            <Card>
-                {/* Toolbar */}
-                <div className="border-b bg-muted/50 p-2 flex space-x-1">
-                    <Button type="button" variant="ghost" size="sm" onClick={() => formatText('bold')}>
-                        <Bold className="h-4 w-4" />
-                    </Button>
-                    <Button type="button" variant="ghost" size="sm" onClick={() => formatText('italic')}>
-                        <Italic className="h-4 w-4" />
-                    </Button>
-                    <Button type="button" variant="ghost" size="sm" onClick={() => formatText('underline')}>
-                        <Underline className="h-4 w-4" />
-                    </Button>
-                </div>
-
-                {/* Editor */}
-                <CardContent>
-                    <div
-                        contentEditable
-                        className="min-h-[120px] p-3 focus:outline-none"
-                        dangerouslySetInnerHTML={{ __html: editorContent }}
-                        onBlur={e => {
-                            const content = e.currentTarget.innerHTML;
-                            handleContentChange(content);
-                            field.onBlur();
-                        }}
-                        onInput={e => {
-                            const content = e.currentTarget.innerHTML;
-                            handleContentChange(content);
-                        }}
-                    />
-                </CardContent>
-            </Card>
-
-            {fieldState.error && <p className="text-sm text-destructive">{fieldState.error.message}</p>}
-        </div>
-    );
-}
-```
+You can discover the required IDs by turning on dev mode:
 
-### Tags Input Component
+![Dev mode](./dev-mode.webp)
 
-```tsx title="src/plugins/my-plugin/dashboard/components/tags-input.tsx"
-import { CustomFormComponentInputProps, Input, Badge, Button } from '@vendure/dashboard';
-import { useState, KeyboardEvent } from 'react';
-import { X } from 'lucide-react';
+and then hovering over any of the form elements will allow you to view the IDs:
 
-export function TagsInputComponent({ field, fieldState }: CustomFormComponentInputProps) {
-    const [inputValue, setInputValue] = useState('');
+![Form element IDs](./locator.webp)
 
-    // Parse tags from string value (comma-separated)
-    const tags = field.value ? field.value.split(',').filter(Boolean) : [];
+## Form Validation
 
-    const addTag = (tag: string) => {
-        const trimmedTag = tag.trim();
-        if (trimmedTag && !tags.includes(trimmedTag)) {
-            const newTags = [...tags, trimmedTag];
-            field.onChange(newTags.join(','));
-        }
-        setInputValue('');
-    };
-
-    const removeTag = (tagToRemove: string) => {
-        const newTags = tags.filter(tag => tag !== tagToRemove);
-        field.onChange(newTags.join(','));
-    };
-
-    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
-        if (e.key === 'Enter' || e.key === ',') {
-            e.preventDefault();
-            addTag(inputValue);
-        } else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0) {
-            removeTag(tags[tags.length - 1]);
-        }
-    };
+Form validation is handled by the `react-hook-form` library, which is used by the Dashboard. Internally,
+the Dashboard uses the `zod` library to validate the form data, based on the configuration of the custom field or
+operation argument.
 
-    return (
-        <div className="space-y-2">
-            {/* Tags Display */}
-            <div className="flex flex-wrap gap-1">
-                {tags.map((tag, index) => (
-                    <Badge key={index} variant="secondary" className="gap-1">
-                        {tag}
-                        <Button
-                            type="button"
-                            variant="ghost"
-                            size="icon"
-                            className="h-4 w-4 p-0 hover:bg-transparent"
-                            onClick={() => removeTag(tag)}
-                        >
-                            <X className="h-3 w-3" />
-                        </Button>
-                    </Badge>
-                ))}
-            </div>
+You can access validation data for the current field or the whole form by using the `useFormContext` hook.
 
-            {/* Input */}
-            <Input
-                value={inputValue}
-                onChange={e => setInputValue(e.target.value)}
-                onKeyDown={handleKeyDown}
-                onBlur={field.onBlur}
-                placeholder="Type a tag and press Enter or comma"
-            />
+:::note Error Messages
+Your component does not need to handle standard error messages - the Dashboard will handle them for you.
 
-            {fieldState.error && <p className="text-sm text-destructive">{fieldState.error.message}</p>}
-        </div>
-    );
-}
-```
-
-## Form Validation Integration
-
-Your custom components integrate seamlessly with React Hook Form's validation system:
+For example, if your custom field specifies a `pattern` property, the Dashboard will automatically display an error message
+if the input does not match the pattern.
+:::
 
 ```tsx title="src/plugins/my-plugin/dashboard/components/validated-input.tsx"
-import { CustomFormComponentInputProps, Input, Alert, AlertDescription } from '@vendure/dashboard';
+import { DashboardFormComponent, Input, Alert, AlertDescription } from '@vendure/dashboard';
+import { useFormContext } from 'react-hook-form';
 import { CheckCircle2 } from 'lucide-react';
 
-export function ValidatedInputComponent({ field, fieldState }: CustomFormComponentInputProps) {
+export const ValidatedInputComponent: DashboardFormComponent = ({ value, onChange, name }) => {
+    const { getFieldState } = useFormContext();
+    const fieldState = getFieldState(name);
+
+    console.log(fieldState);
+    // will log something like this:
+    // {
+    //     "invalid": false,
+    //     "isDirty": false,
+    //     "isValidating": false,
+    //     "isTouched": false
+    // }
+    // You can use this data to display validation errors, etc.
+
     return (
         <div className="space-y-2">
             <Input
@@ -550,71 +306,18 @@ export function ValidatedInputComponent({ field, fieldState }: CustomFormCompone
 }
 ```
 
-## Integration with Dashboard Design System
-
-Leverage the dashboard's existing Shadcn UI components for consistent design:
-
-```tsx title="src/plugins/my-plugin/dashboard/components/enhanced-select.tsx"
-import {
-    CustomFormComponentInputProps,
-    Select,
-    SelectContent,
-    SelectItem,
-    SelectTrigger,
-    SelectValue,
-} from '@vendure/dashboard';
-
-interface Option {
-    value: string;
-    label: string;
-}
-
-export function EnhancedSelectComponent({ field, fieldState }: CustomFormComponentInputProps) {
-    // This could come from props, API call, or static data
-    const options: Option[] = [
-        { value: 'small', label: 'Small' },
-        { value: 'medium', label: 'Medium' },
-        { value: 'large', label: 'Large' },
-        { value: 'xl', label: 'Extra Large' },
-    ];
-
-    return (
-        <div className="space-y-2">
-            <Select value={field.value} onValueChange={field.onChange}>
-                <SelectTrigger className={fieldState.error ? 'border-destructive' : ''}>
-                    <SelectValue placeholder="Select a size..." />
-                </SelectTrigger>
-                <SelectContent>
-                    {options.map(option => (
-                        <SelectItem key={option.value} value={option.value}>
-                            {option.label}
-                        </SelectItem>
-                    ))}
-                </SelectContent>
-            </Select>
-
-            {fieldState.error && <p className="text-sm text-destructive">{fieldState.error.message}</p>}
-        </div>
-    );
-}
-```
-
 :::tip Best Practices
 
 1. **Always use Shadcn UI components** from the `@vendure/dashboard` package for consistent styling
-2. **Handle React Hook Form events properly** - call `field.onChange` and `field.onBlur` appropriately for custom field components
-3. **Use standard input patterns** for input and display components - follow the `value`/`onChange` pattern
-4. **Display validation errors** from `fieldState.error` when they exist (custom field components)
-5. **Use dashboard design tokens** - leverage `text-destructive`, `text-muted-foreground`, etc.
-6. **Provide clear visual feedback** for user interactions
-7. **Handle disabled states** by checking the appropriate props
-8. **Target components precisely** using pageId, blockId, and field for input/display components
-   :::
-
-:::note Component Registration
-All custom form elements must be registered in the `customFormComponents` object before they can be used. For custom field components, the `id` you use when registering is what you reference in the custom field's `ui.component` property. For input and display components, they are automatically applied based on the targeting criteria.
+2. **Handle React Hook Form events properly** - call `onChange` and `onBlur` appropriately for custom field components
+3. **Display validation errors** from `fieldState.error` when they exist (custom field components)
+4. **Use dashboard design tokens** - leverage `text-destructive`, `text-muted-foreground`, etc.
+5. **Provide clear visual feedback** for user interactions
+6. **Handle disabled states** by using the `disabled` prop
+7. **Target components precisely** using pageId, blockId, and field for input components
 :::
 
+
 :::important Design System Consistency
 Always import UI components from the `@vendure/dashboard` package rather than creating custom inputs or buttons. This ensures your components follow the dashboard's design system and remain consistent with future updates.
 :::
@@ -670,6 +373,5 @@ Features include:
 
 For detailed information about specific types of custom form elements, see these dedicated guides:
 
-- **[Input Components](./input-components)** - Learn how to create custom input controls for forms with advanced examples like multi-currency inputs, auto-generating slugs, and rich text editors
-- **[Display Components](./display-components)** - Discover how to customize data visualization with enhanced displays for prices, dates, avatars, and progress indicators
-- **[Relation Selectors](./relation-selectors)** - Build powerful entity selection components with search, pagination, and multi-select capabilities for custom fields and form inputs
+- **[Form component examples](./form-component-examples)** - Detailed examples of how to use the APIs available for custom form components.
+- **[Relation selectors](./relation-selectors)** - Build powerful entity selection components with search, pagination, and multi-select capabilities for custom fields and form inputs

+ 0 - 482
docs/docs/guides/extending-the-dashboard/custom-form-components/input-components.md

@@ -1,482 +0,0 @@
----
-title: 'Input Components'
----
-
-Input components allow you to replace specific input fields in existing dashboard forms with custom implementations. They provide a way to enhance the user experience by offering specialized input controls for particular data types or use cases.
-
-## How Input Components Work
-
-Input components are targeted to specific locations in the dashboard using three identifiers:
-
-- **pageId**: The page where the component should appear (e.g., 'product-detail', 'customer-detail')
-- **blockId**: The form block within that page (e.g., 'product-form', 'customer-info')
-- **field**: The specific field to replace (e.g., 'price', 'email', 'description')
-
-When a form renders a field that matches these criteria, your custom input component will be used instead of the default input.
-
-## Registration Method
-
-Input components are registered by co-locating them with detail form definitions. This approach is consistent and avoids repeating the `pageId`. You can also include display components in the same definition:
-
-```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
-import { defineDashboardExtension } from '@vendure/dashboard';
-import {
-    DescriptionInputComponent,
-    PriceInputComponent,
-    SlugInputComponent,
-    StatusDisplay,
-} from './components';
-
-export default defineDashboardExtension({
-    detailForms: [
-        {
-            pageId: 'product-variant-detail',
-            inputs: [
-                {
-                    blockId: 'main-form',
-                    field: 'description',
-                    component: DescriptionInputComponent,
-                },
-            ],
-            displays: [
-                {
-                    blockId: 'main-form',
-                    field: 'status',
-                    component: StatusDisplay,
-                },
-            ],
-        },
-        {
-            pageId: 'product-detail',
-            inputs: [
-                {
-                    blockId: 'product-form',
-                    field: 'price',
-                    component: PriceInputComponent,
-                },
-                {
-                    blockId: 'product-form',
-                    field: 'slug',
-                    component: SlugInputComponent,
-                },
-            ],
-        },
-    ],
-});
-```
-
-## Basic Input Component
-
-Input components receive standard input props with `value`, `onChange`, and other common properties:
-
-```tsx title="src/plugins/my-plugin/dashboard/components/email-input.tsx"
-import { Input, Button, DataInputComponentProps } from '@vendure/dashboard';
-import { Mail, Check, X } from 'lucide-react';
-import { useState, useEffect } from 'react';
-
-export function EmailInputComponent({ value, onChange, disabled, placeholder }: DataInputComponentProps) {
-    const [isValid, setIsValid] = useState(false);
-    const [isChecking, setIsChecking] = useState(false);
-
-    const validateEmail = (email: string) => {
-        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-        return emailRegex.test(email);
-    };
-
-    useEffect(() => {
-        if (value) {
-            setIsValid(validateEmail(value));
-        }
-    }, [value]);
-
-    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-        const newValue = e.target.value;
-        onChange(newValue);
-        setIsValid(validateEmail(newValue));
-    };
-
-    return (
-        <div className="relative">
-            <div className="absolute left-3 top-1/2 transform -translate-y-1/2">
-                <Mail className="h-4 w-4 text-muted-foreground" />
-            </div>
-
-            <Input
-                type="email"
-                value={value || ''}
-                onChange={handleChange}
-                disabled={disabled}
-                placeholder={placeholder || 'Enter email address'}
-                className="pl-10 pr-10"
-            />
-
-            {value && (
-                <div className="absolute right-3 top-1/2 transform -translate-y-1/2">
-                    {isValid ? (
-                        <Check className="h-4 w-4 text-green-500" />
-                    ) : (
-                        <X className="h-4 w-4 text-red-500" />
-                    )}
-                </div>
-            )}
-        </div>
-    );
-}
-```
-
-## Advanced Examples
-
-### Multi-Currency Price Input
-
-```tsx title="src/plugins/my-plugin/dashboard/components/price-input.tsx"
-import {
-    Input,
-    Button,
-    Select,
-    SelectContent,
-    SelectItem,
-    SelectTrigger,
-    SelectValue,
-    DataInputComponentProps,
-} from '@vendure/dashboard';
-import { useState } from 'react';
-import { DollarSign, Euro, Pound, Yen } from 'lucide-react';
-
-export function PriceInputComponent({ value, onChange, disabled }: DataInputComponentProps) {
-    const [currency, setCurrency] = useState('USD');
-
-    const currencies = [
-        { code: 'USD', symbol: '$', icon: DollarSign, rate: 1 },
-        { code: 'EUR', symbol: '€', icon: Euro, rate: 0.85 },
-        { code: 'GBP', symbol: '£', icon: Pound, rate: 0.73 },
-        { code: 'JPY', symbol: '¥', icon: Yen, rate: 110 },
-    ];
-
-    const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];
-    const Icon = selectedCurrency.icon;
-
-    // Convert price based on exchange rate
-    const displayValue = value ? (value * selectedCurrency.rate).toFixed(2) : '';
-
-    const handleChange = (displayValue: string) => {
-        const numericValue = parseFloat(displayValue) || 0;
-        // Convert back to base currency (USD) for storage
-        const baseValue = numericValue / selectedCurrency.rate;
-        onChange(baseValue);
-    };
-
-    return (
-        <div className="flex space-x-2">
-            <Select value={currency} onValueChange={setCurrency} disabled={disabled}>
-                <SelectTrigger className="w-24">
-                    <SelectValue>
-                        <div className="flex items-center gap-1">
-                            <Icon className="h-4 w-4" />
-                            {currency}
-                        </div>
-                    </SelectValue>
-                </SelectTrigger>
-                <SelectContent>
-                    {currencies.map(curr => {
-                        const CurrIcon = curr.icon;
-                        return (
-                            <SelectItem key={curr.code} value={curr.code}>
-                                <div className="flex items-center gap-2">
-                                    <CurrIcon className="h-4 w-4" />
-                                    {curr.code}
-                                </div>
-                            </SelectItem>
-                        );
-                    })}
-                </SelectContent>
-            </Select>
-
-            <div className="relative flex-1">
-                <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
-                    {selectedCurrency.symbol}
-                </span>
-                <Input
-                    type="number"
-                    value={displayValue}
-                    onChange={e => handleChange(e.target.value)}
-                    disabled={disabled}
-                    className="pl-8"
-                    placeholder="0.00"
-                    step="0.01"
-                />
-            </div>
-        </div>
-    );
-}
-```
-
-### Auto-generating Slug Input
-
-```tsx title="src/plugins/my-plugin/dashboard/components/slug-input.tsx"
-import { Input, Button, Switch, DataInputComponentProps } from '@vendure/dashboard';
-import { useState, useEffect } from 'react';
-import { RefreshCw, Lock, Unlock } from 'lucide-react';
-
-interface SlugInputProps extends DataInputComponentProps {
-    // Additional context that might be passed
-    formData?: { name?: string; title?: string };
-}
-
-export function SlugInputComponent({ value, onChange, disabled, formData }: SlugInputProps) {
-    const [autoGenerate, setAutoGenerate] = useState(!value);
-    const [isGenerating, setIsGenerating] = useState(false);
-
-    const generateSlug = (text: string) => {
-        return text
-            .toLowerCase()
-            .replace(/[^a-z0-9 -]/g, '') // Remove special characters
-            .replace(/\s+/g, '-') // Replace spaces with hyphens
-            .replace(/-+/g, '-') // Replace multiple hyphens with single
-            .trim('-'); // Remove leading/trailing hyphens
-    };
-
-    useEffect(() => {
-        if (autoGenerate && formData?.name) {
-            const newSlug = generateSlug(formData.name);
-            if (newSlug !== value) {
-                onChange(newSlug);
-            }
-        }
-    }, [formData?.name, autoGenerate, onChange, value]);
-
-    const handleManualGenerate = async () => {
-        if (!formData?.name) return;
-
-        setIsGenerating(true);
-        // Simulate API call for slug validation/generation
-        await new Promise(resolve => setTimeout(resolve, 500));
-
-        const newSlug = generateSlug(formData.name);
-        onChange(newSlug);
-        setIsGenerating(false);
-    };
-
-    return (
-        <div className="space-y-2">
-            <div className="flex items-center space-x-2">
-                <Input
-                    value={value || ''}
-                    onChange={e => onChange(e.target.value)}
-                    disabled={disabled || autoGenerate}
-                    placeholder="product-slug"
-                    className="flex-1"
-                />
-
-                <Button
-                    type="button"
-                    variant="outline"
-                    size="icon"
-                    disabled={disabled || !formData?.name || isGenerating}
-                    onClick={handleManualGenerate}
-                >
-                    <RefreshCw className={`h-4 w-4 ${isGenerating ? 'animate-spin' : ''}`} />
-                </Button>
-            </div>
-
-            <div className="flex items-center space-x-2">
-                <Switch checked={autoGenerate} onCheckedChange={setAutoGenerate} disabled={disabled} />
-                <div className="flex items-center space-x-1 text-sm text-muted-foreground">
-                    {autoGenerate ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
-                    <span>Auto-generate from name</span>
-                </div>
-            </div>
-        </div>
-    );
-}
-```
-
-### Rich Text Input with Toolbar
-
-```tsx title="src/plugins/my-plugin/dashboard/components/rich-text-input.tsx"
-import { Button, Card, CardContent, DataInputComponentProps } from '@vendure/dashboard';
-import { useState, useRef } from 'react';
-import { Bold, Italic, Underline, Link, List, ListOrdered } from 'lucide-react';
-
-export function RichTextInputComponent({ value, onChange, disabled, placeholder }: DataInputComponentProps) {
-    const editorRef = useRef<HTMLDivElement>(null);
-    const [isFocused, setIsFocused] = useState(false);
-
-    const formatText = (command: string, value?: string) => {
-        if (disabled) return;
-
-        document.execCommand(command, false, value);
-        editorRef.current?.focus();
-
-        // Update the value
-        if (editorRef.current) {
-            onChange(editorRef.current.innerHTML);
-        }
-    };
-
-    const handleInput = () => {
-        if (editorRef.current) {
-            onChange(editorRef.current.innerHTML);
-        }
-    };
-
-    const toolbarItems = [
-        { command: 'bold', icon: Bold, label: 'Bold' },
-        { command: 'italic', icon: Italic, label: 'Italic' },
-        { command: 'underline', icon: Underline, label: 'Underline' },
-        { command: 'insertUnorderedList', icon: List, label: 'Bullet List' },
-        { command: 'insertOrderedList', icon: ListOrdered, label: 'Numbered List' },
-    ];
-
-    return (
-        <Card className={`${isFocused ? 'ring-2 ring-ring ring-offset-2' : ''}`}>
-            {/* Toolbar */}
-            <div className="border-b bg-muted/50 p-2 flex space-x-1">
-                {toolbarItems.map(item => {
-                    const Icon = item.icon;
-                    return (
-                        <Button
-                            key={item.command}
-                            type="button"
-                            variant="ghost"
-                            size="sm"
-                            disabled={disabled}
-                            onClick={() => formatText(item.command)}
-                            title={item.label}
-                        >
-                            <Icon className="h-4 w-4" />
-                        </Button>
-                    );
-                })}
-
-                <Button
-                    type="button"
-                    variant="ghost"
-                    size="sm"
-                    disabled={disabled}
-                    onClick={() => {
-                        const url = prompt('Enter URL:');
-                        if (url) formatText('createLink', url);
-                    }}
-                    title="Add Link"
-                >
-                    <Link className="h-4 w-4" />
-                </Button>
-            </div>
-
-            {/* Editor */}
-            <CardContent className="p-0">
-                <div
-                    ref={editorRef}
-                    contentEditable={!disabled}
-                    className="min-h-[120px] p-3 focus:outline-none"
-                    dangerouslySetInnerHTML={{ __html: value || '' }}
-                    onInput={handleInput}
-                    onFocus={() => setIsFocused(true)}
-                    onBlur={() => setIsFocused(false)}
-                    data-placeholder={placeholder}
-                    style={
-                        {
-                            '--placeholder-color': 'hsl(var(--muted-foreground))',
-                        } as React.CSSProperties
-                    }
-                />
-            </CardContent>
-        </Card>
-    );
-}
-```
-
-## Finding Page and Block IDs
-
-To target your input components correctly, you need to know the `pageId` and `blockId` values. Here are some common ones:
-
-### Product Pages
-
-```tsx
-// Product detail page
-pageId: 'product-detail';
-blockId: 'product-form';
-// Common fields: name, slug, description, price, sku, enabled
-
-// Product list page
-pageId: 'product-list';
-blockId: 'product-table';
-// Common fields: name, sku, price, enabled, createdAt
-```
-
-### Customer Pages
-
-```tsx
-// Customer detail page
-pageId: 'customer-detail';
-blockId: 'customer-info';
-// Common fields: firstName, lastName, emailAddress, phoneNumber
-
-// Customer list page
-pageId: 'customer-list';
-blockId: 'customer-table';
-// Common fields: firstName, lastName, emailAddress, customerSince
-```
-
-### Order Pages
-
-```tsx
-// Order detail page
-pageId: 'order-detail';
-blockId: 'order-form';
-// Common fields: code, state, orderPlacedAt, customerEmail
-
-// Order list page
-pageId: 'order-list';
-blockId: 'order-table';
-// Common fields: code, state, total, orderPlacedAt
-```
-
-:::tip Finding IDs
-If you're unsure about the exact `pageId` or `blockId`, you can inspect the DOM in your browser's developer tools. Look for `data-page-id` and `data-block-id` attributes on form elements.
-:::
-
-## Component Props
-
-Input components receive these standard props through the `DataInputComponentProps` interface:
-
-```tsx
-import { DataInputComponentProps } from '@vendure/dashboard';
-
-// The DataInputComponentProps interface provides:
-interface DataInputComponentProps {
-    value: any; // Current field value
-    onChange: (value: any) => void; // Function to update the value
-    [key: string]: any; // Additional props that may be passed
-}
-
-// Common additional props that may be available:
-// - disabled?: boolean          // Whether the input is disabled
-// - placeholder?: string        // Placeholder text
-// - required?: boolean         // Whether the field is required
-// - readOnly?: boolean         // Whether the field is read-only
-// - fieldName?: string         // The name of the field
-// - formData?: Record<string, any> // Other form data for context
-```
-
-## Best Practices
-
-1. **Follow standard input patterns**: Use `value` and `onChange` props consistently
-2. **Handle disabled states**: Always respect the `disabled` prop
-3. **Provide visual feedback**: Show loading states, validation status, etc.
-4. **Use dashboard components**: Import from `@vendure/dashboard` for consistency
-5. **Consider accessibility**: Add proper ARIA labels and keyboard navigation
-6. **Test thoroughly**: Ensure your component works in different contexts and with various data types
-
-:::note
-Input components completely replace the default input for the targeted field. Make sure your component handles all the data types and scenarios that the original input would have handled.
-:::
-
-:::warning
-Input components should be focused and specific. If you need to customize multiple fields in a form, consider using custom form components or page blocks instead.
-:::
-
-## Related Guides
-
-- **[Custom Form Elements Overview](./)** - Learn about the unified system for custom field components, input components, and display components
-- **[Display Components](./display-components)** - Create custom readonly data visualizations for tables, detail views, and forms

BIN
docs/docs/guides/extending-the-dashboard/custom-form-components/locator.webp


+ 1 - 1
docs/docs/reference/core-plugins/admin-ui-plugin/admin-ui-plugin-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AdminUiPluginOptions
 
-<GenerationInfo sourceFile="packages/admin-ui-plugin/src/plugin.ts" sourceLine="36" packageName="@vendure/admin-ui-plugin" />
+<GenerationInfo sourceFile="packages/admin-ui-plugin/src/plugin.ts" sourceLine="44" packageName="@vendure/admin-ui-plugin" />
 
 Configuration options for the <a href='/reference/core-plugins/admin-ui-plugin/#adminuiplugin'>AdminUiPlugin</a>.
 

+ 1 - 1
docs/docs/reference/core-plugins/admin-ui-plugin/index.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AdminUiPlugin
 
-<GenerationInfo sourceFile="packages/admin-ui-plugin/src/plugin.ts" sourceLine="131" packageName="@vendure/admin-ui-plugin" />
+<GenerationInfo sourceFile="packages/admin-ui-plugin/src/plugin.ts" sourceLine="139" packageName="@vendure/admin-ui-plugin" />
 
 This plugin starts a static server for the Admin UI app, and proxies it via the `/admin/` path of the main Vendure server.
 

+ 4 - 48
docs/docs/reference/dashboard/extensions/detail-forms.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## DashboardDetailFormInputComponent
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts" sourceLine="16" packageName="@vendure/dashboard" since="3.4.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts" sourceLine="13" packageName="@vendure/dashboard" since="3.4.0" />
 
 Allows you to define custom input components for specific fields in detail forms.
 The pageId is already defined in the detail form extension, so only the blockId and field are needed.
@@ -20,7 +20,7 @@ The pageId is already defined in the detail form extension, so only the blockId
 interface DashboardDetailFormInputComponent {
     blockId: string;
     field: string;
-    component: DataInputComponent;
+    component: DashboardFormComponent;
 }
 ```
 
@@ -38,7 +38,7 @@ The ID of the block where this input component should be used.
 The name of the field where this input component should be used.
 ### component
 
-<MemberInfo kind="property" type={`DataInputComponent`}   />
+<MemberInfo kind="property" type={`<a href='/reference/dashboard/forms/dashboard-form-component#dashboardformcomponent'>DashboardFormComponent</a>`}   />
 
 The React component that will be rendered as the input.
 It should accept `value`, `onChange`, and other standard input props.
@@ -47,47 +47,9 @@ It should accept `value`, `onChange`, and other standard input props.
 </div>
 
 
-## DashboardDetailFormDisplayComponent
-
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts" sourceLine="44" packageName="@vendure/dashboard" since="3.4.0" />
-
-Allows you to define custom display components for specific fields in detail forms.
-The pageId is already defined in the detail form extension, so only the blockId and field are needed.
-
-```ts title="Signature"
-interface DashboardDetailFormDisplayComponent {
-    blockId: string;
-    field: string;
-    component: DataDisplayComponent;
-}
-```
-
-<div className="members-wrapper">
-
-### blockId
-
-<MemberInfo kind="property" type={`string`}   />
-
-The ID of the block where this display component should be used.
-### field
-
-<MemberInfo kind="property" type={`string`}   />
-
-The name of the field where this display component should be used.
-### component
-
-<MemberInfo kind="property" type={`DataDisplayComponent`}   />
-
-The React component that will be rendered as the display.
-It should accept `value` and other standard display props.
-
-
-</div>
-
-
 ## DashboardDetailFormExtensionDefinition
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts" sourceLine="72" packageName="@vendure/dashboard" since="3.4.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts" sourceLine="41" packageName="@vendure/dashboard" since="3.4.0" />
 
 Allows you to extend existing detail forms (e.g. on the product detail or customer detail pages)
 with custom GraphQL queries, input components, and display components.
@@ -97,7 +59,6 @@ interface DashboardDetailFormExtensionDefinition {
     pageId: string;
     extendDetailDocument?: string | DocumentNode | (() => DocumentNode | string);
     inputs?: DashboardDetailFormInputComponent[];
-    displays?: DashboardDetailFormDisplayComponent[];
 }
 ```
 
@@ -119,11 +80,6 @@ fields that can be used by custom input or display components.
 <MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions/detail-forms#dashboarddetailforminputcomponent'>DashboardDetailFormInputComponent</a>[]`}   />
 
 Custom input components for specific fields in the detail form.
-### displays
-
-<MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions/detail-forms#dashboarddetailformdisplaycomponent'>DashboardDetailFormDisplayComponent</a>[]`}   />
-
-Custom display components for specific fields in the detail form.
 
 
 </div>

+ 4 - 4
docs/docs/reference/dashboard/extensions/form-components.md

@@ -11,14 +11,14 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## DashboardCustomFormComponent
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/form-components.ts" sourceLine="13" packageName="@vendure/dashboard" since="3.4.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/form-components.ts" sourceLine="11" packageName="@vendure/dashboard" since="3.4.0" />
 
 Allows you to define custom form components for custom fields in the dashboard.
 
 ```ts title="Signature"
 interface DashboardCustomFormComponent {
     id: string;
-    component: React.FunctionComponent<CustomFormComponentInputProps>;
+    component: DashboardFormComponent;
 }
 ```
 
@@ -32,7 +32,7 @@ A unique identifier for the custom form component. It is a good practice to name
 these IDs to avoid naming collisions, for example `"my-plugin.markdown-editor"`.
 ### component
 
-<MemberInfo kind="property" type={`React.FunctionComponent&#60;CustomFormComponentInputProps&#62;`}   />
+<MemberInfo kind="property" type={`<a href='/reference/dashboard/forms/dashboard-form-component#dashboardformcomponent'>DashboardFormComponent</a>`}   />
 
 The React component that will be rendered as the custom form input.
 
@@ -42,7 +42,7 @@ The React component that will be rendered as the custom form input.
 
 ## DashboardCustomFormComponents
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/form-components.ts" sourceLine="36" packageName="@vendure/dashboard" since="3.4.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/form-components.ts" sourceLine="34" packageName="@vendure/dashboard" since="3.4.0" />
 
 Interface for registering custom field components in the dashboard.
 For input and display components, use the co-located approach with detailForms.

+ 135 - 0
docs/docs/reference/dashboard/forms/dashboard-form-component.md

@@ -0,0 +1,135 @@
+---
+title: "DashboardFormComponent"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## DashboardFormComponent
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/form-engine/form-engine-types.ts" sourceLine="161" packageName="@vendure/dashboard" />
+
+This is the common type for all custom form components registered for:
+
+- custom fields
+- configurable operation args
+- detail page fields
+
+Here's a simple example:
+
+```ts
+import { DashboardFormComponent, Input } from '@vendure/dashboard';
+
+const MyComponent: DashboardFormComponent = (props) => {
+    return <Input value={props.value}
+                  onChange={props.onChange}
+                  onBlur={props.onBlur}
+                  name={props.name}
+                  disabled={props.disabled}
+                  ref={props.ref}
+                  />;
+};
+```
+
+```ts title="Signature"
+type DashboardFormComponent = React.ComponentType<DashboardFormComponentProps> & {
+    metadata?: DashboardFormComponentMetadata;
+}
+```
+
+
+## DashboardFormComponentProps
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/form-engine/form-engine-types.ts" sourceLine="91" packageName="@vendure/dashboard" />
+
+Props that get passed to all form input components. They are based on the
+controller props used by the underlying `react-hook-form`, i.e.:
+
+```ts
+export type ControllerRenderProps = {
+    onChange: (event: any) => void;
+    onBlur: () => void;
+    value: any;
+    disabled?: boolean;
+    name: string;
+    ref: RefCallBack;
+};
+```
+
+in addition, they can optionally be passed a `fieldDef` prop if the
+component is used in the context of a custom field or configurable operation arg.
+
+The `fieldDef` arg, when present, has the following shape:
+
+```ts
+export type ConfigurableArgDef = {
+    defaultValue: any
+    description: string | null
+    label: string | null
+    list: boolean
+    name: string
+    required: boolean
+    type: string
+    ui: any
+}
+```
+
+```ts title="Signature"
+type DashboardFormComponentProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = ControllerRenderProps<TFieldValues, TName> & {
+    fieldDef?: ConfigurableFieldDef;
+}
+```
+
+
+## DashboardFormComponentMetadata
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/form-engine/form-engine-types.ts" sourceLine="122" packageName="@vendure/dashboard" />
+
+Metadata which can be defined on a <a href='/reference/dashboard/forms/dashboard-form-component#dashboardformcomponent'>DashboardFormComponent</a> which
+provides additional information about how the dashboard should render the
+component.
+
+The metadata is defined by adding the static property on the component:
+
+*Example*
+
+```ts
+export const MyCustomInput: DashboardFormComponent = props => {
+  // implementation omitted
+}
+
+// highlight-start
+MyCustomInput.metadata = {
+  isListInput: true
+}
+// highlight-end
+```
+
+```ts title="Signature"
+type DashboardFormComponentMetadata = {
+    isListInput?: boolean | 'dynamic';
+    isFullWidth?: boolean;
+}
+```
+
+<div className="members-wrapper">
+
+### isListInput
+
+<MemberInfo kind="property" type={`boolean | 'dynamic'`}   />
+
+Defines whether this form component is designed to handle list inputs.
+If set to `'dynamic'`, it means the component has internal logic that can
+handle both lists and single values.
+### isFullWidth
+
+<MemberInfo kind="property" type={`boolean`}   />
+
+
+
+
+</div>

+ 14 - 0
docs/docs/reference/dashboard/forms/index.md

@@ -0,0 +1,14 @@
+---
+title: "Forms"
+isDefaultIndex: true
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+import DocCardList from '@theme/DocCardList';
+
+<DocCardList />

+ 1 - 2
docs/sidebars.js

@@ -152,8 +152,7 @@ const sidebars = {
                     label: 'Custom Form Elements',
                     link: { type: 'doc', id: 'guides/extending-the-dashboard/custom-form-components/index' },
                     items: [
-                        'guides/extending-the-dashboard/custom-form-components/input-components',
-                        'guides/extending-the-dashboard/custom-form-components/display-components',
+                        'guides/extending-the-dashboard/custom-form-components/form-component-examples',
                         'guides/extending-the-dashboard/custom-form-components/relation-selectors',
                     ],
                 },

+ 2 - 1
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-modification-preview-dialog.tsx

@@ -161,7 +161,7 @@ export function OrderModificationPreviewDialog({
                     {error && <div className="text-destructive py-2">{error}</div>}
                     {previewOrder && !loading && !error && (
                         <>
-                            <OrderTable order={previewOrder} />
+                            <OrderTable pageId="order-modification-preview" order={previewOrder} />
                             {/* Refund/payment UI using Alert */}
                             {priceDifference < 0 && (
                                 <>
@@ -245,6 +245,7 @@ export function OrderModificationPreviewDialog({
                                                                             render={({ field }) => (
                                                                                 <FormControl>
                                                                                     <MoneyInput
+                                                                                        {...field}
                                                                                         value={
                                                                                             field.value || 0
                                                                                         }

+ 1 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx

@@ -338,6 +338,7 @@ export function AddProductVariantDialog({
                             label={<Trans>Price</Trans>}
                             render={({ field }) => (
                                 <MoneyInput
+                                    {...field}
                                     value={Number(field.value) || 0}
                                     onChange={value => field.onChange(value.toString())}
                                     currency={activeChannel?.defaultCurrencyCode ?? 'USD'}

+ 19 - 5
packages/dashboard/src/lib/components/data-input/affixed-input.tsx

@@ -1,12 +1,17 @@
 import { ReactNode, useEffect, useRef, useState } from 'react';
 import { Input } from '../ui/input.js';
 
-interface AffixedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
-    prefix?: ReactNode;
-    suffix?: ReactNode;
-}
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+
+export type AffixedInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix'> &
+    DashboardFormComponentProps & {
+        prefix?: ReactNode;
+        suffix?: ReactNode;
+    };
 
 export function AffixedInput({ prefix, suffix, className = '', ...props }: Readonly<AffixedInputProps>) {
+    const readOnly = props.disabled || isReadonlyField(props.fieldDef);
     const prefixRef = useRef<HTMLSpanElement>(null);
     const suffixRef = useRef<HTMLSpanElement>(null);
     const [prefixWidth, setPrefixWidth] = useState(0);
@@ -33,7 +38,16 @@ export function AffixedInput({ prefix, suffix, className = '', ...props }: Reado
                     {prefix}
                 </span>
             )}
-            <Input {...props} className={className} style={style} />
+            <Input
+                value={props.value}
+                onChange={props.onChange}
+                onBlur={props.onBlur}
+                type={props.type}
+                ref={props.ref}
+                className={className}
+                style={style}
+                disabled={readOnly}
+            />
             {suffix && (
                 <span ref={suffixRef} className="absolute right-3 text-muted-foreground whitespace-nowrap">
                     {suffix}

+ 9 - 0
packages/dashboard/src/lib/components/data-input/boolean-input.tsx

@@ -0,0 +1,9 @@
+import { Switch } from '@/vdb/components/ui/switch.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+
+export function BooleanInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
+    const checked = typeof value === 'string' ? value === 'true' : value;
+    const readOnly = isReadonlyField(fieldDef);
+    return <Switch checked={checked} onCheckedChange={onChange} disabled={readOnly} />;
+}

+ 8 - 0
packages/dashboard/src/lib/components/data-input/checkbox-input.tsx

@@ -0,0 +1,8 @@
+import { Checkbox } from '../ui/checkbox.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+
+export function CheckboxInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
+    const readOnly = isReadonlyField(fieldDef);
+    return <Checkbox checked={value} onCheckedChange={onChange} disabled={readOnly} />;
+}

+ 11 - 2
packages/dashboard/src/lib/components/data-input/combination-mode-input.tsx

@@ -1,7 +1,16 @@
-import { DataInputComponent } from '@/vdb/framework/component-registry/component-registry.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { Trans } from '@/vdb/lib/trans.js';
 
-export const CombinationModeInput: DataInputComponent = ({ value, onChange, position, ...props }) => {
+export type CombinationModeInputProps = DashboardFormComponentProps & {
+    position?: number;
+};
+
+export const CombinationModeInput = ({
+    value,
+    onChange,
+    position,
+    ...props
+}: Readonly<CombinationModeInputProps>) => {
     const booleanValue = value === 'true' || value === true;
 
     // Only show for items after the first one

+ 26 - 401
packages/dashboard/src/lib/components/data-input/configurable-operation-list-input.tsx

@@ -1,414 +1,39 @@
-import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
-import { ConfigArgType } from '@vendure/core';
-import { Plus, X } from 'lucide-react';
-import { useState } from 'react';
-import { Button } from '../ui/button.js';
-import { Input } from '../ui/input.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
-import { Switch } from '../ui/switch.js';
-import { Textarea } from '../ui/textarea.js';
-import { DateTimeInput } from './datetime-input.js';
-
-export interface EnhancedListInputProps {
-    definition: ConfigurableOperationDefFragment['args'][number];
-    value: string;
-    onChange: (value: string) => void;
-    readOnly?: boolean;
-}
+import { FormControlAdapter } from '@/vdb/framework/form-engine/form-control-adapter.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isCustomFieldConfig } from '@/vdb/framework/form-engine/utils.js';
+import { ControllerRenderProps } from 'react-hook-form';
 
 /**
  * A dynamic array input component for configurable operation arguments that handle lists of values.
- *
- * This component allows users to add, edit, and remove multiple items from an array-type argument.
- * Each item in the array is rendered using the appropriate input control based on the argument's
- * type and UI configuration (e.g., text input, select dropdown, boolean switch, date picker).
- *
- * The component supports:
- * - Adding new items with appropriate input controls
- * - Editing existing items inline
- * - Removing items from the array
- * - Various data types: string, number, boolean, datetime, currency
- * - Multiple UI components: select, textarea, currency input, etc.
- * - Keyboard shortcuts (Enter to add items)
- * - Read-only mode for display purposes
- *
- * Used primarily in configurable operations (promotions, shipping methods, payment methods)
- * where an argument accepts multiple values, such as a list of product IDs, category codes,
- * or discount amounts.
- *
- * @example
- * // For a promotion condition that accepts multiple product category codes
- * <EnhancedListInput
- *   definition={argDefinition}
- *   value='["electronics", "books", "clothing"]'
- *   onChange={handleChange}
- * />
  */
 export function ConfigurableOperationListInput({
-    definition,
+    fieldDef,
     value,
     onChange,
-    readOnly,
-}: Readonly<EnhancedListInputProps>) {
-    const [newItemValue, setNewItemValue] = useState('');
-
-    // Parse the current array value
-    const arrayValue = parseArrayValue(value);
-
-    const handleArrayChange = (newArray: string[]) => {
-        onChange(JSON.stringify(newArray));
-    };
-
-    const handleAddItem = () => {
-        if (newItemValue.trim()) {
-            const newArray = [...arrayValue, newItemValue.trim()];
-            handleArrayChange(newArray);
-            setNewItemValue('');
-        }
-    };
-
-    const handleRemoveItem = (index: number) => {
-        const newArray = arrayValue.filter((_, i) => i !== index);
-        handleArrayChange(newArray);
-    };
-
-    const handleUpdateItem = (index: number, newValue: string) => {
-        const newArray = arrayValue.map((item, i) => (i === index ? newValue : item));
-        handleArrayChange(newArray);
-    };
-
-    const handleKeyPress = (e: React.KeyboardEvent) => {
-        if (e.key === 'Enter' && !e.shiftKey) {
-            e.preventDefault();
-            handleAddItem();
-        }
-    };
-
-    // Render individual item input based on the underlying type
-    const renderItemInput = (itemValue: string, index: number) => {
-        const argType = definition.type as ConfigArgType;
-        const uiComponent = (definition.ui as any)?.component;
-
-        const commonProps = {
-            value: itemValue,
-            onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
-                handleUpdateItem(index, e.target.value),
-            disabled: readOnly,
-        };
-
-        switch (uiComponent) {
-            case 'boolean-form-input':
-                return (
-                    <Switch
-                        checked={itemValue === 'true'}
-                        onCheckedChange={checked => handleUpdateItem(index, checked.toString())}
-                        disabled={readOnly}
-                    />
-                );
-
-            case 'select-form-input': {
-                const options = (definition.ui as any)?.options || [];
-                return (
-                    <Select
-                        value={itemValue}
-                        onValueChange={val => handleUpdateItem(index, val)}
-                        disabled={readOnly}
-                    >
-                        <SelectTrigger>
-                            <SelectValue />
-                        </SelectTrigger>
-                        <SelectContent>
-                            {options.map((option: any) => (
-                                <SelectItem key={option.value} value={option.value}>
-                                    {typeof option.label === 'string'
-                                        ? option.label
-                                        : option.label?.[0]?.value || option.value}
-                                </SelectItem>
-                            ))}
-                        </SelectContent>
-                    </Select>
-                );
-            }
-            case 'textarea-form-input':
-                return (
-                    <Textarea
-                        {...commonProps}
-                        placeholder="Enter text..."
-                        rows={2}
-                        className="bg-background"
-                    />
-                );
-
-            case 'date-form-input':
-                return (
-                    <DateTimeInput
-                        value={itemValue ? new Date(itemValue) : new Date()}
-                        onChange={val => handleUpdateItem(index, val.toISOString())}
-                        disabled={readOnly}
-                    />
-                );
-
-            case 'number-form-input': {
-                const ui = definition.ui as any;
-                const isFloat = argType === 'float';
-                return (
-                    <Input
-                        type="number"
-                        value={itemValue}
-                        onChange={e => handleUpdateItem(index, e.target.value)}
-                        disabled={readOnly}
-                        min={ui?.min}
-                        max={ui?.max}
-                        step={ui?.step || (isFloat ? 0.01 : 1)}
-                    />
-                );
-            }
-            case 'currency-form-input':
-                return (
-                    <div className="flex items-center">
-                        <span className="mr-2 text-sm text-muted-foreground">$</span>
-                        <Input
-                            type="number"
-                            value={itemValue}
-                            onChange={e => handleUpdateItem(index, e.target.value)}
-                            disabled={readOnly}
-                            min={0}
-                            step={1}
-                            className="flex-1"
-                        />
-                    </div>
-                );
-        }
-
-        // Fall back to type-based rendering
-        switch (argType) {
-            case 'boolean':
-                return (
-                    <Switch
-                        checked={itemValue === 'true'}
-                        onCheckedChange={checked => handleUpdateItem(index, checked.toString())}
-                        disabled={readOnly}
-                    />
-                );
-
-            case 'int':
-            case 'float': {
-                const isFloat = argType === 'float';
-                return (
-                    <Input
-                        type="number"
-                        value={itemValue}
-                        onChange={e => handleUpdateItem(index, e.target.value)}
-                        disabled={readOnly}
-                        step={isFloat ? 0.01 : 1}
-                    />
-                );
-            }
-            case 'datetime':
-                return (
-                    <DateTimeInput
-                        value={itemValue ? new Date(itemValue) : new Date()}
-                        onChange={val => handleUpdateItem(index, val.toISOString())}
-                        disabled={readOnly}
-                    />
-                );
-
-            default:
-                return <Input type="text" {...commonProps} placeholder="Enter value..." />;
-        }
-    };
-
-    // Render new item input (similar logic but for newItemValue)
-    const renderNewItemInput = () => {
-        const argType = definition.type as ConfigArgType;
-        const uiComponent = (definition.ui as any)?.component;
-
-        const commonProps = {
-            value: newItemValue,
-            onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
-                setNewItemValue(e.target.value),
-            disabled: readOnly,
-            onKeyPress: handleKeyPress,
-        };
-
-        switch (uiComponent) {
-            case 'boolean-form-input': {
-                return (
-                    <Switch
-                        checked={newItemValue === 'true'}
-                        onCheckedChange={checked => setNewItemValue(checked.toString())}
-                        disabled={readOnly}
-                    />
-                );
-            }
-            case 'select-form-input': {
-                const options = (definition.ui as any)?.options || [];
-                return (
-                    <Select value={newItemValue} onValueChange={setNewItemValue} disabled={readOnly}>
-                        <SelectTrigger>
-                            <SelectValue placeholder="Select value..." />
-                        </SelectTrigger>
-                        <SelectContent>
-                            {options.map((option: any) => (
-                                <SelectItem key={option.value} value={option.value}>
-                                    {typeof option.label === 'string'
-                                        ? option.label
-                                        : option.label?.[0]?.value || option.value}
-                                </SelectItem>
-                            ))}
-                        </SelectContent>
-                    </Select>
-                );
-            }
-            case 'textarea-form-input': {
-                return (
-                    <Textarea
-                        {...commonProps}
-                        placeholder="Enter text..."
-                        rows={2}
-                        className="bg-background"
-                    />
-                );
-            }
-            case 'date-form-input': {
-                return <DateTimeInput value={newItemValue} onChange={setNewItemValue} disabled={readOnly} />;
-            }
-            case 'number-form-input': {
-                const ui = definition.ui as any;
-                const isFloat = argType === 'float';
-                return (
-                    <Input
-                        type="number"
-                        value={newItemValue}
-                        onChange={e => setNewItemValue(e.target.value)}
-                        disabled={readOnly}
-                        min={ui?.min}
-                        max={ui?.max}
-                        step={ui?.step || (isFloat ? 0.01 : 1)}
-                        placeholder="Enter number..."
-                        onKeyPress={handleKeyPress}
-                        className="bg-background"
-                    />
-                );
-            }
-            case 'currency-form-input': {
-                return (
-                    <div className="flex items-center">
-                        <span className="mr-2 text-sm text-muted-foreground">$</span>
-                        <Input
-                            type="number"
-                            value={newItemValue}
-                            onChange={e => setNewItemValue(e.target.value)}
-                            disabled={readOnly}
-                            min={0}
-                            step={1}
-                            placeholder="Enter amount..."
-                            onKeyPress={handleKeyPress}
-                            className="flex-1 bg-background"
-                        />
-                    </div>
-                );
-            }
-        }
-
-        // Fall back to type-based rendering
-        switch (argType) {
-            case 'boolean':
-                return (
-                    <Switch
-                        checked={newItemValue === 'true'}
-                        onCheckedChange={checked => setNewItemValue(checked.toString())}
-                        disabled={readOnly}
-                    />
-                );
-            case 'int':
-            case 'float': {
-                const isFloat = argType === 'float';
-                return (
-                    <Input
-                        type="number"
-                        value={newItemValue}
-                        onChange={e => setNewItemValue(e.target.value)}
-                        disabled={readOnly}
-                        step={isFloat ? 0.01 : 1}
-                        placeholder="Enter number..."
-                        onKeyPress={handleKeyPress}
-                        className="bg-background"
-                    />
-                );
-            }
-            case 'datetime': {
-                return (
-                    <DateTimeInput
-                        value={newItemValue ? new Date(newItemValue) : new Date()}
-                        onChange={val => setNewItemValue(val.toISOString())}
-                        disabled={readOnly}
-                    />
-                );
-            }
-            default: {
-                return (
-                    <Input
-                        type="text"
-                        {...commonProps}
-                        placeholder="Enter value..."
-                        className="bg-background"
-                    />
-                );
-            }
-        }
-    };
-
-    if (readOnly) {
-        return (
-            <div className="space-y-2">
-                {arrayValue.map((item, index) => (
-                    <div key={index + item} className="flex items-center gap-2 p-2 bg-muted rounded-md">
-                        <span className="flex-1">{item}</span>
-                    </div>
-                ))}
-                {arrayValue.length === 0 && <div className="text-sm text-muted-foreground">No items</div>}
-            </div>
-        );
+}: Readonly<DashboardFormComponentProps>) {
+    if (!fieldDef || isCustomFieldConfig(fieldDef)) {
+        return null;
     }
-
+    const arrayValue = parseArrayValue(value);
     return (
         <div className="space-y-2">
-            {/* Existing items */}
-            {arrayValue.map((item, index) => (
-                <div key={index + item} className="flex items-center gap-2">
-                    <div className="flex-1">{renderItemInput(item, index)}</div>
-                    <Button
-                        variant="outline"
-                        size="sm"
-                        onClick={() => handleRemoveItem(index)}
-                        disabled={readOnly}
-                        type="button"
-                    >
-                        <X className="h-4 w-4" />
-                    </Button>
-                </div>
-            ))}
-
-            {/* Add new item */}
-            <div className="flex items-center gap-2 p-2 border border-dashed rounded-md">
-                <div className="flex-1">{renderNewItemInput()}</div>
-                <Button
-                    variant="outline"
-                    size="sm"
-                    onClick={handleAddItem}
-                    disabled={readOnly || !newItemValue.trim()}
-                    type="button"
-                >
-                    <Plus className="h-4 w-4" />
-                </Button>
-            </div>
-
-            {arrayValue.length === 0 && (
-                <div className="text-sm text-muted-foreground">
-                    No items added yet. Use the input above to add items.
-                </div>
-            )}
+            {arrayValue.map((item, index) => {
+                const field = {
+                    value: item,
+                    onChange,
+                    disabled: false,
+                    onBlur: () => {},
+                    name: fieldDef.name,
+                    ref: () => {},
+                } satisfies ControllerRenderProps<any, any>;
+                return (
+                    <div key={`array-item-${index}`} className="flex items-center gap-2">
+                        <div className="flex-1">
+                            <FormControlAdapter field={field} fieldDef={fieldDef} valueMode="native" />
+                        </div>
+                    </div>
+                );
+            })}
         </div>
     );
 }

+ 18 - 25
packages/dashboard/src/lib/components/data-input/custom-field-list-input.tsx

@@ -1,4 +1,5 @@
 import { Button } from '@/vdb/components/ui/button.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { useLingui } from '@/vdb/lib/trans.js';
 import {
     closestCenter,
@@ -26,9 +27,7 @@ interface ListItemWithId {
     value: any;
 }
 
-interface CustomFieldListInputProps {
-    field: ControllerRenderProps<any, any>;
-    disabled?: boolean;
+interface CustomFieldListInputProps extends DashboardFormComponentProps {
     renderInput: (index: number, inputField: ControllerRenderProps<any, any>) => React.ReactNode;
     defaultValue?: any;
 }
@@ -148,7 +147,7 @@ function generateId(): string {
 
 // Convert flat array to array with stable IDs
 function convertToItemsWithIds(values: any[], existingItems?: ListItemWithId[]): ListItemWithId[] {
-    if (!values || values.length === 0) return [];
+    if (!values || !Array.isArray(values) || values.length === 0) return [];
 
     return values.map((value, index) => {
         // Try to reuse existing ID if the value matches and index is within bounds
@@ -170,13 +169,8 @@ function convertToFlatArray(itemsWithIds: ListItemWithId[]): any[] {
     return itemsWithIds.map(item => item.value);
 }
 
-export function CustomFieldListInput({
-    field,
-    disabled,
-    renderInput,
-    defaultValue,
-    isFullWidth = false,
-}: CustomFieldListInputProps & { isFullWidth?: boolean }) {
+export const CustomFieldListInput = ({ renderInput, defaultValue, ...fieldProps }: CustomFieldListInputProps) => {
+    const { value, onChange, disabled } = fieldProps;
     const { i18n } = useLingui();
     const sensors = useSensors(
         useSensor(PointerSensor),
@@ -187,18 +181,18 @@ export function CustomFieldListInput({
 
     // Keep track of items with stable IDs
     const [itemsWithIds, setItemsWithIds] = useState<ListItemWithId[]>(() =>
-        convertToItemsWithIds(field.value || []),
+        convertToItemsWithIds(value || []),
     );
 
     // Update items when field value changes externally (e.g., form reset, initial load)
     useEffect(() => {
-        const newItems = convertToItemsWithIds(field.value || [], itemsWithIds);
+        const newItems = convertToItemsWithIds(value || [], itemsWithIds);
         if (
             JSON.stringify(convertToFlatArray(newItems)) !== JSON.stringify(convertToFlatArray(itemsWithIds))
         ) {
             setItemsWithIds(newItems);
         }
-    }, [field.value, itemsWithIds]);
+    }, [value, itemsWithIds]);
 
     const itemIds = useMemo(() => itemsWithIds.map(item => item._id), [itemsWithIds]);
 
@@ -209,25 +203,25 @@ export function CustomFieldListInput({
         };
         const newItemsWithIds = [...itemsWithIds, newItem];
         setItemsWithIds(newItemsWithIds);
-        field.onChange(convertToFlatArray(newItemsWithIds));
-    }, [itemsWithIds, defaultValue, field]);
+        onChange(convertToFlatArray(newItemsWithIds));
+    }, [itemsWithIds, defaultValue, onChange]);
 
     const handleRemoveItem = useCallback(
         (id: string) => {
             const newItemsWithIds = itemsWithIds.filter(item => item._id !== id);
             setItemsWithIds(newItemsWithIds);
-            field.onChange(convertToFlatArray(newItemsWithIds));
+            onChange(convertToFlatArray(newItemsWithIds));
         },
-        [itemsWithIds, field],
+        [itemsWithIds, onChange],
     );
 
     const handleItemChange = useCallback(
         (id: string, value: any) => {
             const newItemsWithIds = itemsWithIds.map(item => (item._id === id ? { ...item, value } : item));
             setItemsWithIds(newItemsWithIds);
-            field.onChange(convertToFlatArray(newItemsWithIds));
+            onChange(convertToFlatArray(newItemsWithIds));
         },
-        [itemsWithIds, field],
+        [itemsWithIds, onChange],
     );
 
     const handleDragEnd = useCallback(
@@ -240,10 +234,10 @@ export function CustomFieldListInput({
 
                 const newItemsWithIds = arrayMove(itemsWithIds, oldIndex, newIndex);
                 setItemsWithIds(newItemsWithIds);
-                field.onChange(convertToFlatArray(newItemsWithIds));
+                onChange(convertToFlatArray(newItemsWithIds));
             }
         },
-        [itemIds, itemsWithIds, field],
+        [itemIds, itemsWithIds, onChange],
     );
 
     const containerClasses = useMemo(() => {
@@ -278,8 +272,7 @@ export function CustomFieldListInput({
                                 renderInput={renderInput}
                                 onRemove={handleRemoveItem}
                                 onItemChange={handleItemChange}
-                                field={field}
-                                isFullWidth={isFullWidth}
+                                field={fieldProps}
                             />
                         ))}
                     </SortableContext>
@@ -294,4 +287,4 @@ export function CustomFieldListInput({
             )}
         </div>
     );
-}
+};

+ 7 - 11
packages/dashboard/src/lib/components/data-input/customer-group-input.tsx

@@ -4,6 +4,8 @@ import { useQuery } from '@tanstack/react-query';
 import { CustomerGroupChip } from '../shared/customer-group-chip.js';
 import { CustomerGroupSelector } from '../shared/customer-group-selector.js';
 
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+
 const customerGroupsDocument = graphql(`
     query GetCustomerGroups($options: CustomerGroupListOptions) {
         customerGroups(options: $options) {
@@ -20,14 +22,8 @@ export interface CustomerGroup {
     name: string;
 }
 
-export interface CustomerGroupInputProps {
-    value: string;
-    onChange: (value: string) => void;
-    readOnly?: boolean;
-}
-
-export function CustomerGroupInput(props: CustomerGroupInputProps) {
-    const ids = decodeIds(props.value);
+export function CustomerGroupInput({ value, onChange, disabled }: Readonly<DashboardFormComponentProps>) {
+    const ids = decodeIds(value);
     const { data: groups } = useQuery({
         queryKey: ['customerGroups', ids],
         queryFn: () =>
@@ -42,12 +38,12 @@ export function CustomerGroupInput(props: CustomerGroupInputProps) {
 
     const onValueSelectHandler = (value: CustomerGroup) => {
         const newIds = new Set([...ids, value.id]);
-        props.onChange(JSON.stringify(Array.from(newIds)));
+        onChange(JSON.stringify(Array.from(newIds)));
     };
 
     const onValueRemoveHandler = (id: string) => {
         const newIds = new Set(ids.filter(existingId => existingId !== id));
-        props.onChange(JSON.stringify(Array.from(newIds)));
+        onChange(JSON.stringify(Array.from(newIds)));
     };
 
     return (
@@ -58,7 +54,7 @@ export function CustomerGroupInput(props: CustomerGroupInputProps) {
                 ))}
             </div>
 
-            <CustomerGroupSelector onSelect={onValueSelectHandler} readOnly={props.readOnly} />
+            <CustomerGroupSelector onSelect={onValueSelectHandler} readOnly={disabled} />
         </div>
     );
 }

+ 9 - 13
packages/dashboard/src/lib/components/data-input/datetime-input.tsx

@@ -7,24 +7,20 @@ import { Button } from '@/vdb/components/ui/button.js';
 import { Calendar } from '@/vdb/components/ui/calendar.js';
 import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
 import { ScrollArea, ScrollBar } from '@/vdb/components/ui/scroll-area.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { cn } from '@/vdb/lib/utils.js';
 import { CalendarClock } from 'lucide-react';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
 
-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 ?? '');
+export function DateTimeInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
+    const readOnly = isReadonlyField(fieldDef);
+    const date = value && value instanceof Date ? value.toISOString() : (value ?? '');
     const [isOpen, setIsOpen] = React.useState(false);
 
     const hours = Array.from({ length: 12 }, (_, i) => i + 1);
     const handleDateSelect = (selectedDate: Date | undefined) => {
         if (selectedDate) {
-            props.onChange(selectedDate);
+            onChange(selectedDate.toISOString());
         }
     };
 
@@ -39,16 +35,16 @@ export function DateTimeInput(props: DateTimeInputProps) {
                 const currentHours = newDate.getHours();
                 newDate.setHours(value === 'PM' ? currentHours + 12 : currentHours - 12);
             }
-            props.onChange(newDate);
+            onChange(newDate);
         }
     };
 
     return (
-        <Popover open={isOpen} onOpenChange={disabled ? undefined : setIsOpen}>
+        <Popover open={isOpen} onOpenChange={readOnly ? undefined : setIsOpen}>
             <PopoverTrigger asChild>
                 <Button
                     variant="outline"
-                    disabled={disabled}
+                    disabled={readOnly}
                     className={cn(
                         'w-full justify-start text-left font-normal shadow-xs',
                         !date && 'text-muted-foreground',

+ 29 - 9
packages/dashboard/src/lib/components/data-input/default-relation-input.tsx

@@ -5,6 +5,9 @@ import { ControllerRenderProps } from 'react-hook-form';
 import { MultiRelationInput, SingleRelationInput } from './relation-input.js';
 import { createRelationSelectorConfig } from './relation-selector.js';
 
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isRelationCustomFieldConfig } from '@/vdb/framework/form-engine/utils.js';
+
 interface PlaceholderIconProps {
     letter: string;
     className?: string;
@@ -551,8 +554,19 @@ interface DefaultRelationInputProps {
     disabled?: boolean;
 }
 
-export function DefaultRelationInput({ fieldDef, field, disabled }: Readonly<DefaultRelationInputProps>) {
+export function DefaultRelationInput({
+    fieldDef,
+    value,
+    onChange,
+    onBlur,
+    name,
+    ref,
+    disabled,
+}: Readonly<DashboardFormComponentProps>) {
     const { i18n } = useLingui();
+    if (!fieldDef || !isRelationCustomFieldConfig(fieldDef)) {
+        return null;
+    }
     const entityName = fieldDef.entity;
     const ENTITY_CONFIGS = createEntityConfigs(i18n);
     const config = ENTITY_CONFIGS[entityName as keyof typeof ENTITY_CONFIGS];
@@ -562,10 +576,10 @@ export function DefaultRelationInput({ fieldDef, field, disabled }: Readonly<Def
         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}
+                value={value ?? ''}
+                onChange={e => onChange(e.target.value)}
+                onBlur={onBlur}
+                name={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"
@@ -578,8 +592,11 @@ export function DefaultRelationInput({ fieldDef, field, disabled }: Readonly<Def
     if (isList) {
         return (
             <MultiRelationInput
-                value={field.value ?? []}
-                onChange={field.onChange}
+                onBlur={onBlur}
+                name={name}
+                ref={ref}
+                value={value ?? []}
+                onChange={onChange}
                 config={config}
                 disabled={disabled}
                 selectorLabel={<Trans>Select {entityName.toLowerCase()}s</Trans>}
@@ -588,8 +605,11 @@ export function DefaultRelationInput({ fieldDef, field, disabled }: Readonly<Def
     } else {
         return (
             <SingleRelationInput
-                value={field.value ?? ''}
-                onChange={field.onChange}
+                onBlur={onBlur}
+                name={name}
+                ref={ref}
+                value={value ?? ''}
+                onChange={onChange}
                 config={config}
                 disabled={disabled}
                 selectorLabel={<Trans>Select {entityName.toLowerCase()}</Trans>}

+ 15 - 13
packages/dashboard/src/lib/components/data-input/facet-value-input.tsx

@@ -1,3 +1,4 @@
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { api } from '@/vdb/graphql/api.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
 import { useQuery } from '@tanstack/react-query';
@@ -21,14 +22,8 @@ const facetValuesDocument = graphql(`
     }
 `);
 
-export interface FacetValueInputProps {
-    value: string;
-    onChange: (value: string) => void;
-    readOnly?: boolean;
-}
-
-export function FacetValueInput(props: FacetValueInputProps) {
-    const ids = decodeIds(props.value);
+export const FacetValueInput: DashboardFormComponent = ({ value, onChange, disabled }) => {
+    const ids = decodeIds(value);
     const { data } = useQuery({
         queryKey: ['facetValues', ids],
         queryFn: () =>
@@ -43,12 +38,12 @@ export function FacetValueInput(props: FacetValueInputProps) {
 
     const onValueSelectHandler = (value: FacetValue) => {
         const newIds = new Set([...ids, value.id]);
-        props.onChange(JSON.stringify(Array.from(newIds)));
+        onChange(JSON.stringify(Array.from(newIds)));
     };
 
     const onValueRemoveHandler = (id: string) => {
         const newIds = new Set(ids.filter(existingId => existingId !== id));
-        props.onChange(JSON.stringify(Array.from(newIds)));
+        onChange(JSON.stringify(Array.from(newIds)));
     };
 
     return (
@@ -62,12 +57,19 @@ export function FacetValueInput(props: FacetValueInputProps) {
                     />
                 ))}
             </div>
-            <FacetValueSelector onValueSelect={onValueSelectHandler} disabled={props.readOnly} />
+            <FacetValueSelector onValueSelect={onValueSelectHandler} disabled={disabled} />
         </div>
     );
-}
+};
 
-function decodeIds(idsString: string): string[] {
+FacetValueInput.metadata = {
+    isListInput: true,
+};
+
+function decodeIds(idsString: string | string[]): string[] {
+    if (Array.isArray(idsString)) {
+        return idsString;
+    }
     try {
         return JSON.parse(idsString);
     } catch (error) {

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

@@ -9,8 +9,8 @@ export * from './select-with-options.js';
 
 // Enhanced configurable operation input components
 export * from './configurable-operation-list-input.js';
-export * from './customer-group-selector-input.js';
-export * from './product-selector-input.js';
+export * from './customer-group-input.js';
+export * from './product-multi-selector-input.js';
 
 // Relation selector components
 export * from './relation-input.js';

+ 27 - 20
packages/dashboard/src/lib/components/data-input/money-input.tsx

@@ -1,11 +1,21 @@
-import { DataInputComponentProps } from '@/vdb/framework/component-registry/component-registry.js';
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { useEffect, useMemo, useState } from 'react';
 import { AffixedInput } from './affixed-input.js';
 
-// Original component
-function MoneyInputInternal({ value, currency, onChange }: DataInputComponentProps) {
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+
+export interface MoneyInputProps extends DashboardFormComponentProps {
+    currency?: string;
+}
+
+export function MoneyInput(props: Readonly<MoneyInputProps>) {
+    const { value, onChange, currency, ...rest } = props;
+    const { activeChannel } = useChannel();
+    const activeCurrency = currency ?? activeChannel?.defaultCurrencyCode;
+    const readOnly = isReadonlyField(props.fieldDef);
     const {
         settings: { displayLanguage, displayLocale },
     } = useUserSettings();
@@ -19,39 +29,43 @@ function MoneyInputInternal({ value, currency, onChange }: DataInputComponentPro
 
     // Determine if the currency symbol should be a prefix based on locale
     const shouldPrefix = useMemo(() => {
-        if (!currency) return false;
+        if (!activeCurrency) {
+            return false;
+        }
         const locale = displayLocale || displayLanguage.replace(/_/g, '-');
         const parts = new Intl.NumberFormat(locale, {
             style: 'currency',
-            currency,
+            currency: activeCurrency,
             currencyDisplay: 'symbol',
         }).formatToParts();
         const NaNString = parts.find(p => p.type === 'nan')?.value ?? 'NaN';
         const localised = new Intl.NumberFormat(locale, {
             style: 'currency',
-            currency,
+            currency: activeCurrency,
             currencyDisplay: 'symbol',
         }).format(undefined as any);
         return localised.indexOf(NaNString) > 0;
-    }, [currency, displayLocale, displayLanguage]);
+    }, [activeCurrency, displayLocale, displayLanguage]);
 
     // Get the currency symbol
     const currencySymbol = useMemo(() => {
-        if (!currency) return '';
+        if (!activeCurrency) return '';
         const locale = displayLocale || displayLanguage.replace(/_/g, '-');
         const parts = new Intl.NumberFormat(locale, {
             style: 'currency',
-            currency,
+            currency: activeCurrency,
             currencyDisplay: 'symbol',
         }).formatToParts();
-        return parts.find(p => p.type === 'currency')?.value ?? currency;
-    }, [currency, displayLocale, displayLanguage]);
+        return parts.find(p => p.type === 'currency')?.value ?? activeCurrency;
+    }, [activeCurrency, displayLocale, displayLanguage]);
 
     return (
         <AffixedInput
             type="text"
             className="bg-background"
             value={displayValue}
+            disabled={readOnly}
+            {...rest}
             onChange={e => {
                 const inputValue = e.target.value;
                 // Allow empty input
@@ -77,8 +91,8 @@ function MoneyInputInternal({ value, currency, onChange }: DataInputComponentPro
                     }
                 }
             }}
-            onBlur={e => {
-                const inputValue = e.target.value;
+            onBlur={() => {
+                const inputValue = displayValue;
                 if (inputValue === '') {
                     onChange(0);
                     setDisplayValue('0');
@@ -97,10 +111,3 @@ function MoneyInputInternal({ value, currency, onChange }: DataInputComponentPro
         />
     );
 }
-
-// Wrapper that makes it compatible with DataInputComponent
-export function MoneyInput(props: { value: any; onChange: (value: any) => void; [key: string]: any }) {
-    const { value, onChange, ...rest } = props;
-    const currency = rest.currency || 'USD'; // Default currency if none provided
-    return <MoneyInputInternal value={value} currency={currency} onChange={onChange} />;
-}

+ 48 - 0
packages/dashboard/src/lib/components/data-input/number-input.tsx

@@ -0,0 +1,48 @@
+import { AffixedInput } from '@/vdb/components/data-input/affixed-input.js';
+import { Input } from '@/vdb/components/ui/input.js';
+
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+
+export function NumberInput({ fieldDef, onChange, ...fieldProps }: Readonly<DashboardFormComponentProps>) {
+    const readOnly = fieldProps.disabled || isReadonlyField(fieldDef);
+    const isFloat = fieldDef ? fieldDef.type === 'float' : false;
+    const min = fieldDef?.ui?.min;
+    const max = fieldDef?.ui?.max;
+    const step = fieldDef?.ui?.step || (isFloat ? 0.01 : 1);
+    const prefix = fieldDef?.ui?.prefix;
+    const suffix = fieldDef?.ui?.suffix;
+    const shouldUseAffixedInput = prefix || suffix;
+    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        if (readOnly) return;
+        onChange(e.target.valueAsNumber);
+    };
+    if (shouldUseAffixedInput) {
+        return (
+            <AffixedInput
+                {...fieldProps}
+                type="number"
+                onChange={handleChange}
+                min={min}
+                max={max}
+                step={step}
+                prefix={prefix}
+                suffix={suffix}
+                className="bg-background"
+                disabled={readOnly}
+            />
+        );
+    }
+
+    return (
+        <Input
+            type="number"
+            onChange={handleChange}
+            {...fieldProps}
+            min={min}
+            max={max}
+            step={step}
+            disabled={readOnly}
+        />
+    );
+}

+ 16 - 0
packages/dashboard/src/lib/components/data-input/password-input.tsx

@@ -0,0 +1,16 @@
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+import { Input } from '../ui/input.js';
+
+export function PasswordInput(props: Readonly<DashboardFormComponentProps>) {
+    const readOnly = props.disabled || isReadonlyField(props.fieldDef);
+    return (
+        <Input
+            type="password"
+            ref={props.ref}
+            value={props.value}
+            onChange={e => props.onChange(e.target.value)}
+            disabled={readOnly}
+        />
+    );
+}

+ 8 - 15
packages/dashboard/src/lib/components/data-input/product-multi-selector.tsx → packages/dashboard/src/lib/components/data-input/product-multi-selector-input.tsx

@@ -10,7 +10,7 @@ import {
     DialogTitle,
 } from '@/vdb/components/ui/dialog.js';
 import { Input } from '@/vdb/components/ui/input.js';
-import { DataInputComponent } from '@/vdb/framework/component-registry/component-registry.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { api } from '@/vdb/graphql/api.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
 import { Trans } from '@/vdb/lib/trans.js';
@@ -372,21 +372,12 @@ function ProductMultiSelectorDialog({
     );
 }
 
-export const ProductMultiInput: DataInputComponent = ({ value, onChange, ...props }) => {
+export const ProductMultiInput: DashboardFormComponent = ({ value, onChange, ...props }) => {
     const [open, setOpen] = useState(false);
-
     // Parse the configuration from the field definition
-    const mode = (props as any)?.selectionMode === 'variant' ? 'variant' : 'product';
-
+    const mode = props.fieldDef?.ui?.selectionMode === 'variant' ? 'variant' : 'product';
     // Parse the current value (JSON array of IDs)
-    const selectedIds = useMemo(() => {
-        if (!value || typeof value !== 'string') return [];
-        try {
-            return JSON.parse(value);
-        } catch {
-            return [];
-        }
-    }, [value]);
+    const selectedIds = value;
 
     const handleSelectionChange = useCallback(
         (newSelectedIds: string[]) => {
@@ -394,11 +385,9 @@ export const ProductMultiInput: DataInputComponent = ({ value, onChange, ...prop
         },
         [onChange],
     );
-
     const itemType = mode === 'product' ? 'products' : 'variants';
     const buttonText =
         selectedIds.length > 0 ? `Selected ${selectedIds.length} ${itemType}` : `Select ${itemType}`;
-
     return (
         <>
             <div className="space-y-2">
@@ -424,3 +413,7 @@ export const ProductMultiInput: DataInputComponent = ({ value, onChange, ...prop
         </>
     );
 };
+
+ProductMultiInput.metadata = {
+    isListInput: true,
+};

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

@@ -1,12 +1,11 @@
+import { DashboardFormComponent, DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
 import { createRelationSelectorConfig, RelationSelector } from './relation-selector.js';
 
 /**
  * Single relation input component
  */
-export interface SingleRelationInputProps<T = any> {
-    value: string;
-    onChange: (value: string) => void;
+export interface SingleRelationInputProps<T = any> extends DashboardFormComponentProps {
     config: Parameters<typeof createRelationSelectorConfig<T>>[0];
     disabled?: boolean;
     className?: string;
@@ -46,9 +45,7 @@ export function SingleRelationInput<T>({
 /**
  * Multi relation input component
  */
-export interface MultiRelationInputProps<T = any> {
-    value: string[];
-    onChange: (value: string[]) => void;
+export interface MultiRelationInputProps<T = any> extends DashboardFormComponentProps {
     config: Parameters<typeof createRelationSelectorConfig<T>>[0];
     disabled?: boolean;
     className?: string;
@@ -80,6 +77,10 @@ export function MultiRelationInput<T>({
     );
 }
 
+(MultiRelationInput as DashboardFormComponent).metadata = {
+    isListInput: true,
+};
+
 // Example configurations for common entities
 
 /**

+ 10 - 13
packages/dashboard/src/lib/components/data-input/rich-text-input.tsx

@@ -1,9 +1,11 @@
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import TextStyle from '@tiptap/extension-text-style';
 import { BubbleMenu, Editor, EditorContent, useEditor } from '@tiptap/react';
 import StarterKit from '@tiptap/starter-kit';
 import { BoldIcon, ItalicIcon, StrikethroughIcon } from 'lucide-react';
 import { useLayoutEffect, useRef } from 'react';
 import { Button } from '../ui/button.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
 
 // define your extension array
 const extensions = [
@@ -20,13 +22,8 @@ const extensions = [
     }),
 ];
 
-export interface RichTextInputProps {
-    value: string;
-    disabled?: boolean;
-    onChange: (value: string) => void;
-}
-
-export function RichTextInput({ value, onChange, disabled }: Readonly<RichTextInputProps>) {
+export function RichTextInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
+    const readOnly = isReadonlyField(fieldDef);
     const isInternalUpdate = useRef(false);
 
     const editor = useEditor({
@@ -35,16 +32,16 @@ export function RichTextInput({ value, onChange, disabled }: Readonly<RichTextIn
         },
         extensions: extensions,
         content: value,
-        editable: !disabled,
+        editable: !readOnly,    
         onUpdate: ({ editor }) => {
-            if (!disabled) {
+            if (!readOnly) {
                 isInternalUpdate.current = true;
                 onChange(editor.getHTML());
             }
         },
         editorProps: {
             attributes: {
-                class: `border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm max-h-[500px] overflow-y-auto ${disabled ? 'cursor-not-allowed opacity-50' : ''}`,
+                class: `border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm max-h-[500px] overflow-y-auto ${readOnly ? 'cursor-not-allowed opacity-50' : ''}`,
             },
         },
     });
@@ -64,9 +61,9 @@ export function RichTextInput({ value, onChange, disabled }: Readonly<RichTextIn
     // Update editor's editable state when disabled prop changes
     useLayoutEffect(() => {
         if (editor) {
-            editor.setEditable(!disabled);
+            editor.setEditable(!readOnly);
         }
-    }, [disabled, editor]);
+    }, [readOnly, editor]);
 
     if (!editor) {
         return null;
@@ -75,7 +72,7 @@ export function RichTextInput({ value, onChange, disabled }: Readonly<RichTextIn
     return (
         <>
             <EditorContent editor={editor} />
-            <CustomBubbleMenu editor={editor} disabled={disabled} />
+            <CustomBubbleMenu editor={editor} disabled={readOnly} />
         </>
     );
 }

+ 29 - 17
packages/dashboard/src/lib/components/data-input/select-with-options.tsx

@@ -1,15 +1,16 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
+import {
+    DashboardFormComponent,
+    DashboardFormComponentProps,
+    StringCustomFieldConfig,
+} from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField, isStringFieldWithOptions } from '@/vdb/framework/form-engine/utils.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;
+export interface SelectWithOptionsProps extends DashboardFormComponentProps {
     placeholder?: React.ReactNode;
     isListField?: boolean;
 }
@@ -22,12 +23,14 @@ export interface SelectWithOptionsProps {
  * @since 3.3.0
  */
 export function SelectWithOptions({
-    field,
-    options,
-    disabled,
+    value,
+    onChange,
+    fieldDef,
     placeholder,
     isListField = false,
+    disabled,
 }: Readonly<SelectWithOptionsProps>) {
+    const readOnly = disabled || isReadonlyField(fieldDef);
     const {
         settings: { displayLanguage },
     } = useUserSettings();
@@ -37,6 +40,11 @@ export function SelectWithOptions({
         const translation = label.find(t => t.languageCode === displayLanguage);
         return translation?.value ?? label[0]?.value ?? '';
     };
+    if (!fieldDef || !isStringFieldWithOptions(fieldDef)) {
+        return null;
+    }
+    const options: NonNullable<StringCustomFieldConfig['options']> =
+        fieldDef.options ?? fieldDef.ui.options ?? [];
 
     // Convert options to MultiSelect format
     const multiSelectItems = options.map(option => ({
@@ -45,31 +53,31 @@ export function SelectWithOptions({
     }));
 
     // For list fields, use MultiSelect component
-    if (isListField) {
+    if (isListField || fieldDef?.list === true) {
         return (
             <MultiSelect
                 multiple={true}
-                value={field.value || []}
-                onChange={field.onChange}
+                value={value || []}
+                onChange={onChange}
                 items={multiSelectItems}
                 placeholder={placeholder ? String(placeholder) : 'Select options'}
-                className={disabled ? 'opacity-50 pointer-events-none' : ''}
+                className={readOnly ? 'opacity-50 pointer-events-none' : ''}
             />
         );
     }
 
     // For single fields, use regular Select
-    const currentValue = field.value ?? '';
+    const currentValue = value ?? '';
 
     const handleValueChange = (value: string) => {
         if (value) {
-            field.onChange(value);
+            onChange(value);
         }
     };
 
     return (
-        <Select value={currentValue ?? undefined} onValueChange={handleValueChange} disabled={disabled}>
-            <SelectTrigger>
+        <Select value={currentValue ?? undefined} onValueChange={handleValueChange} disabled={readOnly}>
+            <SelectTrigger className="mb-0">
                 <SelectValue placeholder={placeholder || <Trans>Select an option</Trans>} />
             </SelectTrigger>
             <SelectContent>
@@ -82,3 +90,7 @@ export function SelectWithOptions({
         </Select>
     );
 }
+
+(SelectWithOptions as DashboardFormComponent).metadata = {
+    isListInput: 'dynamic',
+};

+ 54 - 59
packages/dashboard/src/lib/components/data-input/struct-form-input.tsx

@@ -10,30 +10,22 @@ import {
 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 { ControllerRenderProps, useFormContext, useWatch } from 'react-hook-form';
 
 // Import the form input component we already have
+import {
+    DashboardFormComponentProps,
+    StructCustomFieldConfig,
+    StructField,
+} from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isStructFieldConfig } from '@/vdb/framework/form-engine/utils.js';
+import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { CustomFieldListInput } from './custom-field-list-input.js';
 import { DateTimeInput } from './datetime-input.js';
 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>;
@@ -84,42 +76,30 @@ function DisplayMode({
     );
 }
 
-export function StructFormInput({ field, fieldDef, control, getTranslation }: StructFormInputProps) {
+export function StructFormInput({ fieldDef, ...field }: Readonly<DashboardFormComponentProps>) {
     const { formatDate } = useLocalFormat();
-    const isReadonly = fieldDef.readonly ?? false;
     const [isEditing, setIsEditing] = useState(false);
+    const { control } = useFormContext();
+    const { value, name } = field;
 
     // Watch the struct field for changes to update display mode
     const watchedStructValue =
         useWatch({
             control,
-            name: field.name,
-            defaultValue: field.value || {},
+            name,
+            defaultValue: 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();
-        }
+    const {
+        settings: { displayLanguage },
+    } = useUserSettings();
+
+    const getTranslation = (input: Array<{ languageCode: string; value: string }> | null | undefined) => {
+        return input?.find(t => t.languageCode === displayLanguage)?.value;
     };
 
+    const isReadonly = fieldDef?.readonly === true;
+
     // Helper function to render individual struct field inputs
     const renderStructFieldInput = (
         structField: StructField,
@@ -135,12 +115,7 @@ export function StructFormInput({ field, fieldDef, control, getTranslation }: St
                     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}
-                            />
+                            <SelectWithOptions {...singleField} fieldDef={stringField} isListField={false} />
                         );
                     }
                     return (
@@ -187,13 +162,7 @@ export function StructFormInput({ field, fieldDef, control, getTranslation }: St
                         />
                     );
                 case 'datetime':
-                    return (
-                        <DateTimeInput
-                            value={singleField.value}
-                            onChange={singleField.onChange}
-                            disabled={isReadonly}
-                        />
-                    );
+                    return <DateTimeInput {...singleField} />;
                 default:
                     return (
                         <Input
@@ -213,8 +182,8 @@ export function StructFormInput({ field, fieldDef, control, getTranslation }: St
             if (stringField.options && stringField.options.length > 0) {
                 return (
                     <SelectWithOptions
-                        field={inputField}
-                        options={stringField.options}
+                        {...inputField}
+                        fieldDef={stringField}
                         disabled={isReadonly}
                         isListField={isList}
                     />
@@ -245,11 +214,10 @@ export function StructFormInput({ field, fieldDef, control, getTranslation }: St
 
             return (
                 <CustomFieldListInput
-                    field={inputField}
+                    {...inputField}
                     disabled={isReadonly}
                     renderInput={(index, listItemField) => renderSingleStructInput(listItemField)}
                     defaultValue={getDefaultValue()}
-                    isFullWidth={needsFullWidth}
                 />
             );
         }
@@ -275,7 +243,7 @@ export function StructFormInput({ field, fieldDef, control, getTranslation }: St
                         </Button>
                     </div>
                 )}
-                {fieldDef.fields.map(structField => (
+                {fieldDef?.fields?.map(structField => (
                     <FormField
                         key={structField.name}
                         control={control}
@@ -309,6 +277,33 @@ export function StructFormInput({ field, fieldDef, control, getTranslation }: St
         [fieldDef, control, field.name, getTranslation, renderStructFieldInput, isReadonly],
     );
 
+    if (!fieldDef || !isStructFieldConfig(fieldDef)) {
+        return null;
+    }
+
+    // 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();
+        }
+    };
+
     return isEditing ? (
         EditMode
     ) : (

+ 9 - 0
packages/dashboard/src/lib/components/data-input/text-input.tsx

@@ -0,0 +1,9 @@
+// Simple built-in components using the single DashboardFormComponent interface
+import { Input } from '@/vdb/components/ui/input.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+
+export const TextInput: DashboardFormComponent = ({ value, onChange, fieldDef }) => {
+    const readOnly = isReadonlyField(fieldDef);
+    return <Input value={value || ''} onChange={e => onChange(e.target.value)} disabled={readOnly} />;
+};

+ 16 - 0
packages/dashboard/src/lib/components/data-input/textarea-input.tsx

@@ -0,0 +1,16 @@
+import { Textarea } from '@/vdb/components/ui/textarea.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+
+export function TextareaInput(props: Readonly<DashboardFormComponentProps>) {
+    const readOnly = props.disabled || isReadonlyField(props.fieldDef);
+    return (
+        <Textarea
+            ref={props.ref}
+            onBlur={props.onBlur}
+            value={props.value}
+            onChange={e => props.onChange(e.target.value)}
+            disabled={readOnly}
+        />
+    );
+}

+ 3 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-number-filter.tsx

@@ -73,6 +73,9 @@ export function DataTableNumberFilter({
         if (mode === 'money') {
             return (
                 <MoneyInput
+                    ref={() => {}}
+                    onBlur={() => {}}
+                    name="amount"
                     value={parseFloat(value) || 0}
                     onChange={newValue => onChange(newValue.toString())}
                     currency={activeChannel?.defaultCurrencyCode ?? 'USD'}

+ 16 - 5
packages/dashboard/src/lib/components/data-table/use-generated-columns.tsx

@@ -1,5 +1,9 @@
-import { DisplayComponent } from '@/vdb/framework/component-registry/dynamic-component.js';
-import { FieldInfo, getTypeFieldInfo, getOperationVariablesFields } from '@/vdb/framework/document-introspection/get-document-structure.js';
+import { DisplayComponent } from '@/vdb/framework/component-registry/display-component.js';
+import {
+    FieldInfo,
+    getOperationVariablesFields,
+    getTypeFieldInfo,
+} from '@/vdb/framework/document-introspection/get-document-structure.js';
 import { api } from '@/vdb/graphql/api.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
@@ -250,14 +254,21 @@ function DeleteMutationRowAction({
 }>) {
     const { refetchPaginatedList } = usePaginatedList();
     const { i18n } = useLingui();
-    
+
     // Inspect the mutation variables to determine if it expects 'id' or 'ids'
     const mutationVariables = getOperationVariablesFields(deleteMutation);
     const hasIdsParameter = mutationVariables.some(field => field.name === 'ids');
-    
+
     const { mutate: deleteMutationFn } = useMutation({
         mutationFn: api.mutate(deleteMutation),
-        onSuccess: (result: { [key: string]: { result: 'DELETED' | 'NOT_DELETED'; message: string } | { result: 'DELETED' | 'NOT_DELETED'; message: string }[] }) => {
+        onSuccess: (result: {
+            [key: string]:
+                | { result: 'DELETED' | 'NOT_DELETED'; message: string }
+                | {
+                      result: 'DELETED' | 'NOT_DELETED';
+                      message: string;
+                  }[];
+        }) => {
             const unwrappedResult = Object.values(result)[0];
             // Handle both single result and array of results
             const resultToCheck = Array.isArray(unwrappedResult) ? unwrappedResult[0] : unwrappedResult;

+ 3 - 10
packages/dashboard/src/lib/components/shared/configurable-operation-arg-input.tsx

@@ -1,26 +1,21 @@
 import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
-import { configArgToUniversal } from './universal-field-definition.js';
-import { UniversalFormInput } from './universal-form-input.js';
+import { FormControlAdapter } from '../../framework/form-engine/form-control-adapter.js';
 
 export interface ConfigurableOperationArgInputProps {
     definition: ConfigurableOperationDefFragment['args'][number];
     readOnly?: boolean;
     value: string;
     onChange: (value: any) => void;
-    position?: number;
 }
 
 export function ConfigurableOperationArgInput({
     definition,
     value,
     onChange,
-    readOnly,
-    position,
 }: Readonly<ConfigurableOperationArgInputProps>) {
-    const universalFieldDef = configArgToUniversal(definition);
     return (
-        <UniversalFormInput
-            fieldDef={universalFieldDef}
+        <FormControlAdapter
+            fieldDef={definition}
             field={{
                 value,
                 onChange,
@@ -29,8 +24,6 @@ export function ConfigurableOperationArgInput({
                 ref: () => {},
             }}
             valueMode="json-string"
-            disabled={readOnly}
-            position={position}
         />
     );
 }

+ 1 - 6
packages/dashboard/src/lib/components/shared/configurable-operation-input.tsx

@@ -22,7 +22,6 @@ export function ConfigurableOperationInput({
     operationDefinition,
     readonly,
     removable,
-    position,
     hideDescription,
     value,
     onChange,
@@ -90,10 +89,7 @@ export function ConfigurableOperationInput({
                                     className={`grid gap-4 ${operationDefinition.args.length === 1 ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}`}
                                 >
                                     {operationDefinition.args
-                                        .filter(
-                                            arg =>
-                                                arg.ui?.component !== 'combination-mode-form-input',
-                                        )
+                                        .filter(arg => arg.ui?.component !== 'combination-mode-form-input')
                                         .map(arg => {
                                             const argValue =
                                                 value.arguments.find(a => a.name === arg.name)?.value || '';
@@ -114,7 +110,6 @@ export function ConfigurableOperationInput({
                                                                         handleInputChange(arg.name, value)
                                                                     }
                                                                     readOnly={readonly}
-                                                                    position={position}
                                                                 />
                                                             </FormControl>
                                                         </FormItem>

+ 8 - 5
packages/dashboard/src/lib/components/shared/configurable-operation-multi-selector.tsx

@@ -1,3 +1,4 @@
+import { CombinationModeInput } from '@/vdb/components/data-input/combination-mode-input.js';
 import { Button } from '@/vdb/components/ui/button.js';
 import {
     DropdownMenu,
@@ -5,7 +6,6 @@ import {
     DropdownMenuItem,
     DropdownMenuTrigger,
 } from '@/vdb/components/ui/dropdown-menu.js';
-import { InputComponent } from '@/vdb/framework/component-registry/dynamic-component.js';
 import { api } from '@/vdb/graphql/api.js';
 import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { Trans } from '@/vdb/lib/trans.js';
@@ -179,13 +179,14 @@ export function ConfigurableOperationMultiSelector({
                         if (!operationDef) {
                             return null;
                         }
-                        const hasCombinationMode = operation.arguments.find(arg => arg.name === 'combineWithAnd');
+                        const hasCombinationMode = operation.arguments.find(
+                            arg => arg.name === 'combineWithAnd',
+                        );
                         return (
                             <div key={index + operation.code}>
                                 {index > 0 && hasCombinationMode ? (
                                     <div className="my-2">
-                                        <InputComponent
-                                            id="vendure:combinationModeInput"
+                                        <CombinationModeInput
                                             value={
                                                 operation.arguments.find(arg => arg.name === 'combineWithAnd')
                                                     ?.value ?? 'true'
@@ -193,6 +194,9 @@ export function ConfigurableOperationMultiSelector({
                                             onChange={(newValue: boolean | string) =>
                                                 onCombinationModeChange(index, newValue)
                                             }
+                                            name={''}
+                                            ref={() => {}}
+                                            onBlur={() => {}}
                                             position={index}
                                         />
                                     </div>
@@ -204,7 +208,6 @@ export function ConfigurableOperationMultiSelector({
                                     value={operation}
                                     onChange={value => onOperationValueChange(operation, value)}
                                     onRemove={() => onOperationRemove(index)}
-                                    position={index}
                                 />
                             </div>
                         );

+ 5 - 5
packages/dashboard/src/lib/components/shared/configurable-operation-selector.tsx

@@ -41,19 +41,19 @@ type QueryData = {
 
 /**
  * ConfigurableOperationSelector - A reusable component for selecting a single configurable operation
- * 
+ *
  * This component provides a standardized interface for selecting configurable operations such as:
  * - Payment method handlers
- * - Payment eligibility checkers  
+ * - Payment eligibility checkers
  * - Shipping calculators
  * - Shipping eligibility checkers
- * 
+ *
  * Features:
  * - Displays the selected operation with its configuration form
  * - Provides a dropdown to select from available operations
  * - Handles operation selection with default argument values
  * - Supports removal of selected operations
- * 
+ *
  * @example
  * ```tsx
  * <ConfigurableOperationSelector
@@ -122,7 +122,7 @@ export function ConfigurableOperationSelector({
                     <ConfigurableOperationInput
                         operationDefinition={operationDef}
                         value={value}
-                        onChange={value => onOperationValueChange(value)}
+                        onChange={v => onOperationValueChange(v)}
                         onRemove={() => onOperationRemove()}
                     />
                 </div>

+ 20 - 49
packages/dashboard/src/lib/components/shared/custom-fields-form.tsx

@@ -17,9 +17,8 @@ import { customFieldConfigFragment } from '@/vdb/providers/server-config.js';
 import { ResultOf } from 'gql.tada';
 import React, { useMemo } from 'react';
 import { Control } from 'react-hook-form';
+import { FormControlAdapter } from '../../framework/form-engine/form-control-adapter.js';
 import { TranslatableFormField } from './translatable-form-field.js';
-import { customFieldToUniversal } from './universal-field-definition.js';
-import { UniversalFormInput } from './universal-form-input.js';
 
 type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
 
@@ -30,15 +29,7 @@ interface CustomFieldsFormProps {
 }
 
 export function CustomFieldsForm({ entityType, control, formPathPrefix }: Readonly<CustomFieldsFormProps>) {
-    const {
-        settings: { displayLanguage },
-    } = useUserSettings();
     const { i18n } = useLingui();
-
-    const getTranslation = (input: Array<{ languageCode: string; value: string }> | null | undefined) => {
-        return input?.find(t => t.languageCode === displayLanguage)?.value;
-    };
-
     const customFields = useCustomFieldConfig(entityType);
 
     const getCustomFieldBaseName = (fieldDef: CustomFieldConfig) => {
@@ -94,7 +85,6 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Readon
                         fieldDef={fieldDef}
                         control={control}
                         fieldName={getFieldName(fieldDef)}
-                        getTranslation={getTranslation}
                     />
                 ))}
             </div>
@@ -120,7 +110,6 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Readon
                                 fieldDef={fieldDef}
                                 control={control}
                                 fieldName={getFieldName(fieldDef)}
-                                getTranslation={getTranslation}
                             />
                         ))}
                     </div>
@@ -134,12 +123,16 @@ interface CustomFieldItemProps {
     fieldDef: CustomFieldConfig;
     control: Control<any, any>;
     fieldName: string;
-    getTranslation: (
-        input: Array<{ languageCode: string; value: string }> | null | undefined,
-    ) => string | undefined;
 }
 
-function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Readonly<CustomFieldItemProps>) {
+function CustomFieldItem({ fieldDef, control, fieldName }: Readonly<CustomFieldItemProps>) {
+    const {
+        settings: { displayLanguage },
+    } = useUserSettings();
+
+    const getTranslation = (input: Array<{ languageCode: string; value: string }> | null | undefined) => {
+        return input?.find(t => t.languageCode === displayLanguage)?.value;
+    };
     const hasCustomFormComponent = fieldDef.ui?.component;
     const isLocaleField = fieldDef.type === 'localeString' || fieldDef.type === 'localeText';
     const shouldBeFullWidth = fieldDef.ui?.fullWidth === true;
@@ -160,22 +153,13 @@ function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Reado
                                 {hasCustomFormComponent ? (
                                     <CustomFormComponent
                                         fieldDef={fieldDef}
-                                        fieldProps={{
-                                            ...props,
-                                            field: {
-                                                ...field,
-                                                disabled: fieldDef.readonly ?? false,
-                                            },
-                                        }}
+                                        {...field}
                                     />
                                 ) : (
-                                    <UniversalFormInput
-                                        fieldDef={customFieldToUniversal(fieldDef)}
+                                    <FormControlAdapter
+                                        fieldDef={fieldDef}
                                         field={field}
                                         valueMode="native"
-                                        disabled={fieldDef.readonly ?? false}
-                                        control={control}
-                                        getTranslation={getTranslation}
                                     />
                                 )}
                             </FormControl>
@@ -203,13 +187,7 @@ function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Reado
                         >
                             <CustomFormComponent
                                 fieldDef={fieldDef}
-                                fieldProps={{
-                                    ...fieldProps,
-                                    field: {
-                                        ...fieldProps.field,
-                                        disabled: fieldDef.readonly ?? false,
-                                    },
-                                }}
+                                {...fieldProps.field}
                             />
                         </CustomFieldFormItem>
                     )}
@@ -234,14 +212,12 @@ function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Reado
                                 <FormLabel>{getTranslation(fieldDef.label) ?? fieldDef.name}</FormLabel>
                                 <FormControl>
                                     <CustomFieldListInput
-                                        field={field}
+                                        {...field}
                                         disabled={isReadonly}
                                         renderInput={(index, inputField) => (
                                             <StructFormInput
-                                                field={inputField}
-                                                fieldDef={fieldDef as any}
-                                                control={control}
-                                                getTranslation={getTranslation}
+                                                {...inputField}
+                                                fieldDef={fieldDef}
                                             />
                                         )}
                                         defaultValue={{}} // Empty struct object as default
@@ -268,10 +244,8 @@ function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Reado
                             <FormLabel>{getTranslation(fieldDef.label) ?? fieldDef.name}</FormLabel>
                             <FormControl>
                                 <StructFormInput
-                                    field={field}
-                                    fieldDef={fieldDef as any}
-                                    control={control}
-                                    getTranslation={getTranslation}
+                                    {...field}
+                                    fieldDef={fieldDef}
                                 />
                             </FormControl>
                             <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
@@ -295,13 +269,10 @@ function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: Reado
                         getTranslation={getTranslation}
                         fieldName={fieldDef.name}
                     >
-                        <UniversalFormInput
-                            fieldDef={customFieldToUniversal(fieldDef)}
+                        <FormControlAdapter
+                            fieldDef={fieldDef}
                             field={field}
                             valueMode="native"
-                            disabled={fieldDef.readonly ?? false}
-                            control={control}
-                            getTranslation={getTranslation}
                         />
                     </CustomFieldFormItem>
                 )}

+ 0 - 393
packages/dashboard/src/lib/components/shared/direct-form-component-map.tsx

@@ -1,393 +0,0 @@
-import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
-import React from 'react';
-
-import { AffixedInput } from '@/vdb/components/data-input/affixed-input.js';
-import { CombinationModeInput } from '@/vdb/components/data-input/combination-mode-input.js';
-import { DateTimeInput } from '@/vdb/components/data-input/datetime-input.js';
-import { DefaultRelationInput } from '@/vdb/components/data-input/default-relation-input.js';
-import { FacetValueInput } from '@/vdb/components/data-input/facet-value-input.js';
-import { MoneyInput } from '@/vdb/components/data-input/money-input.js';
-import { ProductMultiInput } from '@/vdb/components/data-input/product-multi-selector.js';
-import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
-import { Input } from '@/vdb/components/ui/input.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
-import { Switch } from '@/vdb/components/ui/switch.js';
-import { Textarea } from '@/vdb/components/ui/textarea.js';
-
-import { UniversalFieldDefinition } from './universal-field-definition.js';
-import { transformValue, ValueMode } from './value-transformers.js';
-
-/**
- * Custom hook to handle value transformation between native and JSON string modes
- * Eliminates duplication across form input components
- */
-function useValueTransformation(
-    field: { value: any; onChange: (value: any) => void },
-    fieldDef: UniversalFieldDefinition,
-    valueMode: ValueMode,
-) {
-    const transformedValue = React.useMemo(() => {
-        return valueMode === 'json-string'
-            ? transformValue(field.value, fieldDef, valueMode, 'parse')
-            : field.value;
-    }, [field.value, fieldDef, valueMode]);
-
-    const handleChange = React.useCallback(
-        (newValue: any) => {
-            const serializedValue =
-                valueMode === 'json-string'
-                    ? transformValue(newValue, fieldDef, valueMode, 'serialize')
-                    : newValue;
-            field.onChange(serializedValue);
-        },
-        [field.onChange, fieldDef, valueMode],
-    );
-
-    return { transformedValue, handleChange };
-}
-
-export interface DirectFormComponentProps {
-    fieldDef: UniversalFieldDefinition;
-    field: {
-        value: any;
-        onChange: (value: any) => void;
-        onBlur?: () => void;
-        name: string;
-        ref?: any;
-    };
-    valueMode: ValueMode;
-    disabled?: boolean;
-}
-
-/**
- * Text input wrapper for config args
- */
-const TextFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const handleChange = React.useCallback(
-        (e: React.ChangeEvent<HTMLInputElement>) => {
-            // For both modes, text values are stored as strings
-            field.onChange(e.target.value);
-        },
-        [field.onChange],
-    );
-
-    const value = field.value || '';
-
-    return (
-        <Input
-            type="text"
-            value={value}
-            onChange={handleChange}
-            onBlur={field.onBlur}
-            name={field.name}
-            disabled={disabled}
-            className={valueMode === 'json-string' ? 'bg-background' : undefined}
-        />
-    );
-};
-
-/**
- * Number input wrapper for config args
- */
-const NumberFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const ui = fieldDef.ui;
-    const isFloat = fieldDef.type === 'float';
-    const min = ui?.min;
-    const max = ui?.max;
-    const step = ui?.step || (isFloat ? 0.01 : 1);
-    const prefix = ui?.prefix;
-    const suffix = ui?.suffix;
-
-    const handleChange = React.useCallback(
-        (newValue: number | '') => {
-            if (valueMode === 'json-string') {
-                // For config args, store as string
-                field.onChange(newValue === '' ? '' : newValue.toString());
-            } else {
-                // For custom fields, store as number or undefined
-                field.onChange(newValue === '' ? undefined : newValue);
-            }
-        },
-        [field.onChange, valueMode],
-    );
-
-    // Parse current value to number
-    const numericValue = React.useMemo(() => {
-        if (field.value === undefined || field.value === null || field.value === '') {
-            return '';
-        }
-        const parsed = typeof field.value === 'number' ? field.value : parseFloat(field.value);
-        return isNaN(parsed) ? '' : parsed;
-    }, [field.value]);
-
-    // Use AffixedInput if we have prefix/suffix or for config args mode
-    if (prefix || suffix || valueMode === 'json-string') {
-        return (
-            <AffixedInput
-                type="number"
-                value={numericValue}
-                onChange={e => {
-                    const val = e.target.valueAsNumber;
-                    handleChange(isNaN(val) ? '' : val);
-                }}
-                disabled={disabled}
-                min={min}
-                max={max}
-                step={step}
-                prefix={prefix}
-                suffix={suffix}
-                className="bg-background"
-            />
-        );
-    }
-
-    return (
-        <Input
-            type="number"
-            value={numericValue}
-            onChange={e => {
-                const val = e.target.valueAsNumber;
-                handleChange(isNaN(val) ? '' : val);
-            }}
-            onBlur={field.onBlur}
-            name={field.name}
-            disabled={disabled}
-            min={min}
-            max={max}
-            step={step}
-        />
-    );
-};
-
-/**
- * Boolean input wrapper
- */
-const BooleanFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    // Parse the current value to boolean
-    const currentValue = React.useMemo(() => {
-        if (valueMode === 'json-string') {
-            return field.value === 'true' || field.value === true;
-        } else {
-            return Boolean(field.value);
-        }
-    }, [field.value, valueMode]);
-
-    // Simple change handler - directly call field.onChange
-    const handleChange = React.useCallback(
-        (newValue: boolean) => {
-            if (valueMode === 'json-string') {
-                field.onChange(newValue.toString());
-            } else {
-                field.onChange(newValue);
-            }
-        },
-        [field.onChange, valueMode],
-    );
-
-    return <Switch checked={currentValue} onCheckedChange={handleChange} disabled={disabled} />;
-};
-
-/**
- * Currency input wrapper (uses MoneyInput)
- */
-const CurrencyFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-
-    return <MoneyInput value={transformedValue} onChange={handleChange} disabled={disabled} />;
-};
-
-/**
- * Date input wrapper
- */
-const DateFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-
-    return <DateTimeInput value={transformedValue} onChange={handleChange} disabled={disabled} />;
-};
-
-/**
- * Select input wrapper
- */
-const SelectFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-    const options = fieldDef.ui?.options || [];
-
-    return (
-        <Select value={transformedValue || ''} onValueChange={handleChange} disabled={disabled}>
-            <SelectTrigger className="bg-background mb-0">
-                <SelectValue placeholder="Select an option..." />
-            </SelectTrigger>
-            <SelectContent>
-                {options.map(option => (
-                    <SelectItem key={option.value} value={option.value}>
-                        {typeof option.label === 'string'
-                            ? option.label
-                            : Array.isArray(option.label)
-                              ? option.label[0]?.value || option.value
-                              : option.value}
-                    </SelectItem>
-                ))}
-            </SelectContent>
-        </Select>
-    );
-};
-
-/**
- * Textarea input wrapper
- */
-const TextareaFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-
-    const handleTextareaChange = React.useCallback(
-        (e: React.ChangeEvent<HTMLTextAreaElement>) => {
-            handleChange(e.target.value);
-        },
-        [handleChange],
-    );
-
-    return (
-        <Textarea
-            value={transformedValue || ''}
-            onChange={handleTextareaChange}
-            disabled={disabled}
-            spellCheck={fieldDef.ui?.spellcheck ?? true}
-            placeholder="Enter text..."
-            rows={4}
-            className="bg-background"
-        />
-    );
-};
-
-/**
- * Product selector wrapper (uses DefaultRelationInput)
- */
-const ProductSelectorFormInput: React.FC<DirectFormComponentProps> = ({
-    field,
-    disabled,
-    fieldDef,
-    valueMode,
-}) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-    const entityType = fieldDef.ui?.selectionMode === 'variant' ? 'ProductVariant' : 'Product';
-
-    return (
-        <DefaultRelationInput
-            fieldDef={
-                {
-                    entity: entityType,
-                    list: fieldDef.list,
-                } as any
-            }
-            field={{
-                ...field,
-                value: transformedValue,
-                onChange: handleChange,
-            }}
-            disabled={disabled}
-        />
-    );
-};
-
-/**
- * Customer group input wrapper
- */
-const CustomerGroupFormInput: React.FC<DirectFormComponentProps> = ({
-    field,
-    disabled,
-    fieldDef,
-    valueMode,
-}) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-
-    return (
-        <DefaultRelationInput
-            fieldDef={
-                {
-                    entity: 'CustomerGroup',
-                    list: fieldDef.list,
-                } as any
-            }
-            field={{
-                ...field,
-                value: transformedValue,
-                onChange: handleChange,
-            }}
-            disabled={disabled}
-        />
-    );
-};
-
-/**
- * Password input wrapper (uses regular Input with type="password")
- */
-const PasswordFormInput: React.FC<DirectFormComponentProps> = ({ field, disabled, fieldDef, valueMode }) => {
-    const { transformedValue, handleChange } = useValueTransformation(field, fieldDef, valueMode);
-
-    const handleInputChange = React.useCallback(
-        (e: React.ChangeEvent<HTMLInputElement>) => {
-            handleChange(e.target.value);
-        },
-        [handleChange],
-    );
-
-    return (
-        <Input
-            type="password"
-            value={transformedValue || ''}
-            onChange={handleInputChange}
-            onBlur={field.onBlur}
-            name={field.name}
-            disabled={disabled}
-            className={valueMode === 'json-string' ? 'bg-background' : undefined}
-        />
-    );
-};
-
-/**
- * Direct mapping from DefaultFormComponentId to React components
- * This eliminates the need for intermediate registry IDs
- */
-export const DIRECT_FORM_COMPONENT_MAP: Record<DefaultFormComponentId, React.FC<DirectFormComponentProps>> = {
-    'boolean-form-input': BooleanFormInput,
-    'currency-form-input': CurrencyFormInput,
-    'customer-group-form-input': CustomerGroupFormInput,
-    'date-form-input': DateFormInput,
-    'facet-value-form-input': ({ field, disabled }) => (
-        <FacetValueInput value={field.value} onChange={field.onChange} readOnly={disabled} />
-    ),
-    'json-editor-form-input': TextareaFormInput, // Fallback to textarea for now
-    'html-editor-form-input': ({ field, disabled }) => (
-        <RichTextInput value={field.value} onChange={field.onChange} disabled={disabled} />
-    ),
-    'number-form-input': NumberFormInput,
-    'password-form-input': PasswordFormInput,
-    'product-selector-form-input': ProductSelectorFormInput,
-    'relation-form-input': ProductSelectorFormInput, // Uses same relation logic
-    'rich-text-form-input': ({ field, disabled }) => (
-        <RichTextInput value={field.value} onChange={field.onChange} disabled={disabled} />
-    ),
-    'select-form-input': SelectFormInput,
-    'text-form-input': TextFormInput,
-    'textarea-form-input': TextareaFormInput,
-    'product-multi-form-input': ({ field, disabled, fieldDef }) => (
-        <ProductMultiInput
-            value={field.value}
-            onChange={field.onChange}
-            disabled={disabled}
-            selectionMode={fieldDef.ui?.selectionMode as any}
-        />
-    ),
-    'combination-mode-form-input': ({ field, disabled }) => (
-        <CombinationModeInput value={field.value} onChange={field.onChange} disabled={disabled} />
-    ),
-    'struct-form-input': TextareaFormInput, // Fallback for now
-};
-
-/**
- * Get a direct form component by ID
- */
-export function getDirectFormComponent(
-    componentId: DefaultFormComponentId,
-): React.FC<DirectFormComponentProps> | undefined {
-    return DIRECT_FORM_COMPONENT_MAP[componentId];
-}

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

@@ -63,7 +63,7 @@ export function MultiSelect<T extends boolean>(props: MultiSelectProps<T>) {
 
     const renderTrigger = () => {
         if (multiple) {
-            const selectedValues = value as string[];
+            const selectedValues: string[] = typeof value === 'string' ? [value] : value;
             return (
                 <Button
                     variant="outline"

+ 0 - 118
packages/dashboard/src/lib/components/shared/universal-field-definition.ts

@@ -1,118 +0,0 @@
-import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
-import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
-import { ConfigArgType, CustomFieldType, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
-
-/**
- * Universal field definition that can represent both custom fields and configurable operation args
- */
-export interface UniversalFieldDefinition {
-    name: string;
-    type: CustomFieldType | 'ID'; // Extends CustomFieldType with ID for config args
-    list?: boolean;
-    readonly?: boolean;
-    ui?: {
-        component?: DefaultFormComponentId | string;
-        options?: Array<{ value: string; label: string | Array<{ languageCode: string; value: string }> }>;
-        min?: number;
-        max?: number;
-        step?: number;
-        prefix?: string;
-        suffix?: string;
-        tab?: string;
-        fullWidth?: boolean;
-        spellcheck?: boolean;
-        selectionMode?: string;
-    };
-    entity?: string; // for relations
-    label?: string | Array<{ languageCode: string; value: string }>;
-    description?: string | Array<{ languageCode: string; value: string }>;
-}
-
-/**
- * Convert a custom field config to universal field definition
- */
-export function customFieldToUniversal(fieldDef: CustomFieldConfig): UniversalFieldDefinition {
-    const hasOptions = (fieldDef as any).options;
-    const hasUi = fieldDef.ui;
-    const hasNumericConfig =
-        (fieldDef as any).min !== undefined ||
-        (fieldDef as any).max !== undefined ||
-        (fieldDef as any).step !== undefined;
-
-    return {
-        name: fieldDef.name,
-        type: fieldDef.type as any,
-        list: fieldDef.list ?? false,
-        readonly: fieldDef.readonly ?? false,
-        ui:
-            hasUi || hasOptions || hasNumericConfig
-                ? {
-                      component: fieldDef.ui?.component,
-                      options: (fieldDef as any).options,
-                      ...((fieldDef as any).min != null && {
-                          min: (fieldDef as any).min,
-                      }),
-                      ...((fieldDef as any).max != null && {
-                          max: (fieldDef as any).max,
-                      }),
-                      ...((fieldDef as any).step != null && {
-                          step: (fieldDef as any).step,
-                      }),
-                      tab: fieldDef.ui?.tab,
-                      fullWidth: fieldDef.ui?.fullWidth,
-                  }
-                : undefined,
-        entity: (fieldDef as any).entity,
-        label: fieldDef.label,
-        description: fieldDef.description,
-    };
-}
-
-/**
- * Convert a configurable operation arg definition to universal field definition
- */
-export function configArgToUniversal(
-    definition: ConfigurableOperationDefFragment['args'][number],
-): UniversalFieldDefinition {
-    const ui = definition.ui;
-
-    return {
-        name: definition.name,
-        type: mapConfigArgType(definition.type as ConfigArgType),
-        list: definition.list ?? false,
-        readonly: false,
-        ui: ui
-            ? {
-                  component: ui.component,
-                  options: ui.options,
-                  min: ui.min ?? undefined,
-                  max: ui.max ?? undefined,
-                  step: ui.step ?? undefined,
-                  prefix: ui.prefix,
-                  suffix: ui.suffix,
-                  spellcheck: ui.spellcheck,
-                  selectionMode: ui.selectionMode,
-              }
-            : undefined,
-        entity: getEntityFromUiComponent(ui?.component),
-        label: definition.label,
-        description: definition.description,
-    };
-}
-
-function mapConfigArgType(configArgType: ConfigArgType): UniversalFieldDefinition['type'] {
-    // All ConfigArgType values are compatible with our extended type
-    return configArgType as UniversalFieldDefinition['type'];
-}
-
-function getEntityFromUiComponent(component?: string): string | undefined {
-    switch (component) {
-        case 'product-selector-form-input':
-        case 'product-multi-form-input':
-            return 'Product';
-        case 'customer-group-form-input':
-            return 'CustomerGroup';
-        default:
-            return undefined;
-    }
-}

+ 0 - 175
packages/dashboard/src/lib/components/shared/universal-form-input.tsx

@@ -1,175 +0,0 @@
-import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
-import { ControllerRenderProps } from 'react-hook-form';
-
-import { CustomFieldListInput } from '@/vdb/components/data-input/custom-field-list-input.js';
-import { StructFormInput } from '@/vdb/components/data-input/struct-form-input.js';
-import {
-    CustomFormComponent,
-    CustomFormComponentInputProps,
-} from '@/vdb/framework/form-engine/custom-form-component.js';
-
-import { ConfigurableOperationListInput } from '../data-input/configurable-operation-list-input.js';
-import { FacetValueInput } from '../data-input/facet-value-input.js';
-import { getDirectFormComponent } from './direct-form-component-map.js';
-import { UniversalFieldDefinition } from './universal-field-definition.js';
-import { UniversalInputComponent } from './universal-input-components.js';
-import { ValueMode } from './value-transformers.js';
-
-export interface UniversalFormInputProps {
-    fieldDef: UniversalFieldDefinition;
-    field: ControllerRenderProps<any, any>;
-    valueMode: ValueMode;
-    disabled?: boolean;
-    // Additional props for config args mode
-    position?: number;
-    // Additional props for custom fields mode
-    control?: any;
-    getTranslation?: (
-        input: Array<{ languageCode: string; value: string }> | null | undefined,
-    ) => string | undefined;
-}
-
-/**
- * Universal form input component that handles both custom fields and configurable operation args
- * Maintains full backward compatibility with existing APIs while eliminating duplication
- */
-export function UniversalFormInput({
-    fieldDef,
-    field,
-    valueMode,
-    disabled = false,
-    position,
-    control,
-    getTranslation,
-}: Readonly<UniversalFormInputProps>) {
-    const uiComponent = fieldDef.ui?.component;
-    const isList = fieldDef.list ?? false;
-    const isReadonly = disabled || fieldDef.readonly;
-
-    // Handle special case: facet-value-form-input (only in config args)
-    if (uiComponent === 'facet-value-form-input' && valueMode === 'json-string') {
-        return <FacetValueInput value={field.value} onChange={field.onChange} readOnly={isReadonly} />;
-    }
-
-    // Handle custom form components (custom fields mode)
-    if (uiComponent && valueMode === 'native') {
-        const fieldProps: CustomFormComponentInputProps = {
-            field: {
-                ...field,
-                disabled: isReadonly,
-            },
-            fieldState: {} as any, // This would be passed from the parent FormField
-            formState: {} as any, // This would be passed from the parent FormField
-        };
-
-        return <CustomFormComponent fieldDef={fieldDef as any} fieldProps={fieldProps} />;
-    }
-
-    // Handle direct component mapping (config args mode)
-    if (uiComponent && valueMode === 'json-string') {
-        const DirectComponent = getDirectFormComponent(uiComponent as DefaultFormComponentId);
-        if (DirectComponent) {
-            return (
-                <DirectComponent
-                    fieldDef={fieldDef}
-                    field={field}
-                    valueMode={valueMode}
-                    disabled={isReadonly}
-                />
-            );
-        }
-    }
-
-    // Handle struct fields (custom fields mode only)
-    if (fieldDef.type === 'struct' && valueMode === 'native') {
-        if (isList) {
-            return (
-                <CustomFieldListInput
-                    field={field}
-                    disabled={isReadonly}
-                    renderInput={(index, inputField) => (
-                        <StructFormInput
-                            field={inputField}
-                            fieldDef={fieldDef as any}
-                            control={control}
-                            getTranslation={getTranslation}
-                        />
-                    )}
-                    defaultValue={{}}
-                    isFullWidth={true}
-                />
-            );
-        }
-
-        return (
-            <StructFormInput
-                field={field}
-                fieldDef={fieldDef as any}
-                control={control}
-                getTranslation={getTranslation}
-            />
-        );
-    }
-
-    // Handle list fields
-    if (isList) {
-        if (valueMode === 'json-string') {
-            // Use ConfigurableOperationListInput for config args
-            return (
-                <ConfigurableOperationListInput
-                    definition={fieldDef as any}
-                    value={field.value}
-                    onChange={field.onChange}
-                    readOnly={isReadonly}
-                />
-            );
-        } else {
-            // Use CustomFieldListInput for custom fields
-            const getDefaultValue = () => {
-                switch (fieldDef.type) {
-                    case 'string':
-                    case 'localeString':
-                    case 'localeText':
-                        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) => (
-                        <UniversalInputComponent
-                            fieldDef={{ ...fieldDef, list: false }}
-                            field={inputField}
-                            valueMode={valueMode}
-                            disabled={isReadonly}
-                        />
-                    )}
-                    defaultValue={getDefaultValue()}
-                />
-            );
-        }
-    }
-
-    // Fall back to consolidated input component
-    return (
-        <UniversalInputComponent
-            fieldDef={fieldDef}
-            field={field}
-            valueMode={valueMode}
-            disabled={isReadonly}
-        />
-    );
-}

+ 0 - 291
packages/dashboard/src/lib/components/shared/universal-input-components.tsx

@@ -1,291 +0,0 @@
-import React from 'react';
-import { ControllerRenderProps } from 'react-hook-form';
-
-import { AffixedInput } from '../data-input/affixed-input.js';
-import { DateTimeInput } from '../data-input/datetime-input.js';
-import { DefaultRelationInput } from '../data-input/default-relation-input.js';
-import { SelectWithOptions } from '../data-input/select-with-options.js';
-import { Input } from '../ui/input.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
-import { Switch } from '../ui/switch.js';
-import { Textarea } from '../ui/textarea.js';
-import { UniversalFieldDefinition } from './universal-field-definition.js';
-import { ValueMode, transformValue } from './value-transformers.js';
-
-export interface UniversalInputComponentProps {
-    fieldDef: UniversalFieldDefinition;
-    field: ControllerRenderProps<any, any>;
-    valueMode: ValueMode;
-    disabled?: boolean;
-}
-
-// Component renderer interface for cleaner separation
-interface ComponentRendererProps {
-    fieldDef: UniversalFieldDefinition;
-    field: ControllerRenderProps<any, any>;
-    valueMode: ValueMode;
-    isReadonly: boolean;
-    transformedValue: any;
-    handleChange: (value: any) => void;
-    handleNumericChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
-    handleRegularNumericChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
-    handleTextareaChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
-    handleTextChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
-}
-
-/**
- * Renders relation input component
- */
-function renderRelationInput({ fieldDef, field, transformedValue, handleChange, isReadonly }: ComponentRendererProps) {
-    if (fieldDef.type !== 'relation' || !fieldDef.entity) return null;
-    
-    return (
-        <DefaultRelationInput
-            fieldDef={{
-                entity: fieldDef.entity,
-                list: fieldDef.list,
-            } as any}
-            field={{
-                ...field,
-                value: transformedValue,
-                onChange: handleChange,
-            }}
-            disabled={isReadonly}
-        />
-    );
-}
-
-/**
- * Renders string field with options as select dropdown
- */
-function renderSelectInput({ fieldDef, valueMode, transformedValue, handleChange, isReadonly, field }: ComponentRendererProps) {
-    if (fieldDef.type !== 'string' || !fieldDef.ui?.options) return null;
-
-    if (valueMode === 'json-string') {
-        return (
-            <Select value={transformedValue || ''} onValueChange={handleChange} disabled={isReadonly}>
-                <SelectTrigger className="bg-background mb-0">
-                    <SelectValue placeholder="Select an option..." />
-                </SelectTrigger>
-                <SelectContent>
-                    {fieldDef.ui.options.map((option) => (
-                        <SelectItem key={option.value} value={option.value}>
-                            {typeof option.label === 'string'
-                                ? option.label
-                                : Array.isArray(option.label)
-                                ? option.label[0]?.value || option.value
-                                : option.value}
-                        </SelectItem>
-                    ))}
-                </SelectContent>
-            </Select>
-        );
-    }
-
-    return (
-        <SelectWithOptions
-            field={{
-                ...field,
-                value: transformedValue,
-                onChange: handleChange,
-            }}
-            options={fieldDef.ui.options as any}
-            disabled={isReadonly}
-            isListField={fieldDef.list}
-        />
-    );
-}
-
-/**
- * Renders numeric input components (int/float)
- */
-function renderNumericInput({ fieldDef, valueMode, transformedValue, handleNumericChange, handleRegularNumericChange, isReadonly, field }: ComponentRendererProps) {
-    if (fieldDef.type !== 'int' && fieldDef.type !== 'float') return null;
-
-    const isFloat = fieldDef.type === 'float';
-    const min = fieldDef.ui?.min;
-    const max = fieldDef.ui?.max;
-    const step = fieldDef.ui?.step || (isFloat ? 0.01 : 1);
-    const prefix = fieldDef.ui?.prefix;
-    const suffix = fieldDef.ui?.suffix;
-
-    const shouldUseAffixedInput = prefix || suffix || valueMode === 'json-string';
-
-    if (shouldUseAffixedInput) {
-        const numericValue = transformedValue !== undefined && transformedValue !== '' 
-            ? (typeof transformedValue === 'number' ? transformedValue : parseFloat(transformedValue) || '') 
-            : '';
-
-        return (
-            <AffixedInput
-                type="number"
-                value={numericValue}
-                onChange={handleNumericChange}
-                disabled={isReadonly}
-                min={min}
-                max={max}
-                step={step}
-                prefix={prefix}
-                suffix={suffix}
-                className="bg-background"
-            />
-        );
-    }
-
-    return (
-        <Input
-            type="number"
-            value={transformedValue ?? ''}
-            onChange={handleRegularNumericChange}
-            onBlur={field.onBlur}
-            name={field.name}
-            disabled={isReadonly}
-            min={min}
-            max={max}
-            step={step}
-        />
-    );
-}
-
-/**
- * Renders boolean input as switch
- */
-function renderBooleanInput({ fieldDef, valueMode, transformedValue, handleChange, isReadonly }: ComponentRendererProps) {
-    if (fieldDef.type !== 'boolean') return null;
-
-    const boolValue = valueMode === 'json-string' 
-        ? (transformedValue === true || transformedValue === 'true')
-        : transformedValue;
-
-    return (
-        <Switch
-            checked={boolValue}
-            onCheckedChange={handleChange}
-            disabled={isReadonly}
-        />
-    );
-}
-
-/**
- * Renders datetime input
- */
-function renderDateTimeInput({ fieldDef, transformedValue, handleChange, isReadonly }: ComponentRendererProps) {
-    if (fieldDef.type !== 'datetime') return null;
-
-    return (
-        <DateTimeInput
-            value={transformedValue}
-            onChange={handleChange}
-            disabled={isReadonly}
-        />
-    );
-}
-
-/**
- * Renders textarea for specific config args
- */
-function renderTextareaInput({ fieldDef, valueMode, transformedValue, handleTextareaChange, isReadonly }: ComponentRendererProps) {
-    if (valueMode !== 'json-string' || fieldDef.ui?.component !== 'textarea-form-input') return null;
-
-    return (
-        <Textarea
-            value={transformedValue || ''}
-            onChange={handleTextareaChange}
-            disabled={isReadonly}
-            spellCheck={fieldDef.ui?.spellcheck ?? true}
-            placeholder="Enter text..."
-            rows={4}
-            className="bg-background"
-        />
-    );
-}
-
-/**
- * Renders default text input
- */
-function renderTextInput({ valueMode, transformedValue, handleTextChange, isReadonly, field }: ComponentRendererProps) {
-    return (
-        <Input
-            type="text"
-            value={transformedValue ?? ''}
-            onChange={handleTextChange}
-            onBlur={field.onBlur}
-            name={field.name}
-            disabled={isReadonly}
-            placeholder={valueMode === 'json-string' ? "Enter value..." : undefined}
-            className={valueMode === 'json-string' ? "bg-background" : undefined}
-        />
-    );
-}
-
-/**
- * Consolidated input component for rendering form inputs based on field type
- * This replaces the duplicate implementations in custom fields and config args
- */
-export function UniversalInputComponent({
-    fieldDef,
-    field,
-    valueMode,
-    disabled = false,
-}: Readonly<UniversalInputComponentProps>) {
-    const isReadonly = disabled || fieldDef.readonly;
-
-    // Transform the field value for the component
-    const transformedValue = React.useMemo(() => {
-        return valueMode === 'json-string' 
-            ? transformValue(field.value, fieldDef, valueMode, 'parse')
-            : field.value;
-    }, [field.value, fieldDef, valueMode]);
-
-    // Transform onChange handler for the component
-    const handleChange = React.useCallback((newValue: any) => {
-        const serializedValue = valueMode === 'json-string'
-            ? transformValue(newValue, fieldDef, valueMode, 'serialize')
-            : newValue;
-        field.onChange(serializedValue);
-    }, [field.onChange, fieldDef, valueMode]);
-
-    // Pre-define all change handlers at the top level to follow Rules of Hooks
-    const handleNumericChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-        const val = e.target.valueAsNumber;
-        handleChange(isNaN(val) ? (valueMode === 'json-string' ? '' : undefined) : val);
-    }, [handleChange, valueMode]);
-
-    const handleRegularNumericChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-        const val = e.target.valueAsNumber;
-        handleChange(isNaN(val) ? undefined : val);
-    }, [handleChange]);
-
-    const handleTextareaChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
-        handleChange(e.target.value);
-    }, [handleChange]);
-
-    const handleTextChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-        handleChange(e.target.value);
-    }, [handleChange]);
-
-    // Create props object for all renderers
-    const rendererProps: ComponentRendererProps = {
-        fieldDef,
-        field,
-        valueMode,
-        isReadonly,
-        transformedValue,
-        handleChange,
-        handleNumericChange,
-        handleRegularNumericChange,
-        handleTextareaChange,
-        handleTextChange,
-    };
-
-    // Try each renderer in order, return the first match
-    return (
-        renderRelationInput(rendererProps) ||
-        renderSelectInput(rendererProps) ||
-        renderNumericInput(rendererProps) ||
-        renderBooleanInput(rendererProps) ||
-        renderDateTimeInput(rendererProps) ||
-        renderTextareaInput(rendererProps) ||
-        renderTextInput(rendererProps)
-    );
-}

+ 9 - 32
packages/dashboard/src/lib/framework/component-registry/component-registry.tsx

@@ -1,28 +1,24 @@
+import {
+    DashboardFormComponent,
+    DashboardFormComponentProps,
+} from '@/vdb/framework/form-engine/form-engine-types.js';
 import * as React from 'react';
-import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
-import { addDisplayComponent, getDisplayComponent } from '../extension-api/display-component-extensions.js';
-import { addInputComponent, getInputComponent } from '../extension-api/input-component-extensions.js';
+import { getDisplayComponent } from '../extension-api/display-component-extensions.js';
+import { getInputComponent } from '../extension-api/input-component-extensions.js';
 
 export interface ComponentRegistryEntry<Props extends Record<string, any>> {
     component: React.ComponentType<Props>;
 }
 
-// Basic component types
-
+// Display component interface (unchanged)
 export interface DataDisplayComponentProps {
     value: any;
-    [key: string]: any;
-}
 
-export interface DataInputComponentProps<
-    TFieldValues extends FieldValues = FieldValues,
-    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
-> extends ControllerRenderProps<TFieldValues, TName> {
     [key: string]: any;
 }
 
 export type DataDisplayComponent = React.ComponentType<DataDisplayComponentProps>;
-export type DataInputComponent = React.ComponentType<DataInputComponentProps>;
+export type { DashboardFormComponentProps as DataInputComponentProps };
 
 // Component registry hook that uses the global registry
 export function useComponentRegistry() {
@@ -30,27 +26,8 @@ export function useComponentRegistry() {
         getDisplayComponent: (id: string): DataDisplayComponent | undefined => {
             return getDisplayComponent(id);
         },
-        getInputComponent: (id: string): DataInputComponent | undefined => {
+        getInputComponent: (id: string): DashboardFormComponent | undefined => {
             return getInputComponent(id);
         },
     };
 }
-
-// Legacy registration functions - these now delegate to the global registry
-export function registerInputComponent(
-    pageId: string,
-    blockId: string,
-    field: string,
-    component: DataInputComponent,
-) {
-    addInputComponent({ pageId, blockId, field, component });
-}
-
-export function registerDisplayComponent(
-    pageId: string,
-    blockId: string,
-    field: string,
-    component: DataDisplayComponent,
-) {
-    addDisplayComponent({ pageId, blockId, field, component });
-}

+ 28 - 0
packages/dashboard/src/lib/framework/component-registry/display-component.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import { useComponentRegistry } from './component-registry.js';
+
+/**
+ * @description
+ * This component is used to delegate the rendering of a component to the component registry.
+ *
+ * @example
+ * ```ts
+ * <Delegate component="money.display.default" value={100} />
+ * ```
+ *
+ * @returns
+ */
+export function DisplayComponent(
+    props: Readonly<{
+        id: string;
+        value: any;
+    }>,
+): React.ReactNode {
+    const { getDisplayComponent } = useComponentRegistry();
+    const Component = getDisplayComponent(props.id.toString());
+    if (!Component) {
+        throw new Error(`Component with id ${props.id.toString()} not found`);
+    }
+    const { value, ...rest } = props;
+    return <Component value={value} {...rest} />;
+}

+ 0 - 58
packages/dashboard/src/lib/framework/component-registry/dynamic-component.tsx

@@ -1,58 +0,0 @@
-import React from "react";
-import { useComponentRegistry } from "./component-registry.js";
-
-export type DisplayComponentProps<
-    T extends keyof (typeof COMPONENT_REGISTRY)['dataDisplay'] | string,
-> = {
-    id: T;
-    value: any;
-    // rest of the props are passed to the component
-    [key: string]: any;
-}
-
-
-export type InputComponentProps<
-    T extends keyof (typeof COMPONENT_REGISTRY)['dataInput'] | string,
-> = {
-    id: T;
-    value: any;
-    // rest of the props are passed to the component
-    [key: string]: any;
-}
-
-/**
- * @description
- * This component is used to delegate the rendering of a component to the component registry.
- * 
- * @example
- * ```ts
- * <Delegate component="money.display.default" value={100} />
- * ```
- * 
- * @returns 
- */
-export function DisplayComponent<
-    T extends keyof (typeof COMPONENT_REGISTRY)['dataDisplay'] | string,
->(props: Readonly<DisplayComponentProps<T>>): React.ReactNode {   
-    const { getDisplayComponent } = useComponentRegistry();
-    const Component = getDisplayComponent(props.id);
-    if (!Component) {
-        throw new Error(`Component with id ${props.id} not found`);
-    }
-    const { value, ...rest } = props;
-    return <Component value={value} {...rest} />;
-}
-
-export function InputComponent<
-    T extends keyof (typeof COMPONENT_REGISTRY)['dataInput'] | string,
->(props: Readonly<InputComponentProps<T>>): React.ReactNode {
-    const { getInputComponent } = useComponentRegistry();
-    const Component = getInputComponent(props.id);
-    if (!Component) {
-        throw new Error(`Component with id ${props.id} not found`);
-    }
-    const { value, onChange, ...rest } = props;
-    return <Component value={value} onChange={onChange} {...rest} />;
-}       
-
-

+ 0 - 14
packages/dashboard/src/lib/framework/extension-api/display-component-extensions.tsx

@@ -22,20 +22,6 @@ export function getDisplayComponent(id: string): DataDisplayComponent | undefine
     return globalRegistry.get('displayComponents').get(id);
 }
 
-/**
- * @description
- * Gets a display component using the targeting properties.
- * Uses the same key pattern as registration: pageId_blockId_fieldName
- */
-export function getTargetedDisplayComponent(
-    pageId: string,
-    blockId: string,
-    field: string,
-): DataDisplayComponent | undefined {
-    const key = generateDisplayComponentKey(pageId, blockId, field);
-    return globalRegistry.get('displayComponents').get(key);
-}
-
 /**
  * @description
  * Generates a display component key based on the targeting properties.

+ 52 - 34
packages/dashboard/src/lib/framework/extension-api/input-component-extensions.tsx

@@ -1,43 +1,45 @@
 import { CombinationModeInput } from '@/vdb/components/data-input/combination-mode-input.js';
-import { DateTimeInput } from '@/vdb/components/data-input/datetime-input.js';
-import { FacetValueInput } from '@/vdb/components/data-input/facet-value-input.js';
-import { MoneyInput } from '@/vdb/components/data-input/money-input.js';
-import { ProductMultiInput } from '@/vdb/components/data-input/product-multi-selector.js';
-import { Checkbox } from '@/vdb/components/ui/checkbox.js';
-import { Input } from '@/vdb/components/ui/input.js';
-import { DataInputComponent } from '../component-registry/component-registry.js';
+import { DefaultRelationInput } from '@/vdb/components/data-input/default-relation-input.js';
+import {
+    CustomerGroupInput,
+    FacetValueInput,
+    MoneyInput,
+    ProductMultiInput,
+    RichTextInput,
+    SelectWithOptions,
+} from '@/vdb/components/data-input/index.js';
+import { PasswordInput } from '@/vdb/components/data-input/password-input.js';
+import { TextareaInput } from '@/vdb/components/data-input/textarea-input.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { globalRegistry } from '../registry/global-registry.js';
 
-globalRegistry.register('inputComponents', new Map<string, DataInputComponent>());
-
-// Create component functions for built-in components
-const TextInput: DataInputComponent = props => (
-    <Input {...props} onChange={e => props.onChange(e.target.value)} />
-);
-const NumberInput: DataInputComponent = props => (
-    <Input {...props} onChange={e => props.onChange(e.target.valueAsNumber)} type="number" />
-);
-const CheckboxInput: DataInputComponent = props => (
-    <Checkbox
-        {...props}
-        checked={props.value === 'true' || props.value === true}
-        onCheckedChange={value => props.onChange(value)}
-    />
-);
+globalRegistry.register('inputComponents', new Map<string, DashboardFormComponent>());
 
 // Register built-in input components
 const inputComponents = globalRegistry.get('inputComponents');
-inputComponents.set('vendure:moneyInput', MoneyInput);
-inputComponents.set('vendure:textInput', TextInput);
-inputComponents.set('vendure:numberInput', NumberInput);
-inputComponents.set('vendure:dateTimeInput', DateTimeInput);
-inputComponents.set('vendure:checkboxInput', CheckboxInput);
-inputComponents.set('vendure:facetValueInput', FacetValueInput);
-inputComponents.set('vendure:combinationModeInput', CombinationModeInput);
-inputComponents.set('vendure:productMultiInput', ProductMultiInput);
+inputComponents.set('facet-value-input', FacetValueInput);
+inputComponents.set('combination-mode-input', CombinationModeInput);
+inputComponents.set('product-multi-input', ProductMultiInput);
+inputComponents.set('currency-form-input', MoneyInput);
+inputComponents.set('customer-group-form-input', CustomerGroupInput);
+inputComponents.set('facet-value-form-input', FacetValueInput);
+inputComponents.set('json-editor-form-input', TextareaInput);
+inputComponents.set('textarea-form-input', TextareaInput);
+inputComponents.set('html-editor-form-input', RichTextInput);
+inputComponents.set('rich-text-form-input', RichTextInput);
+inputComponents.set('password-form-input', PasswordInput);
+inputComponents.set('product-selector-form-input', DefaultRelationInput);
+inputComponents.set('relation-form-input', DefaultRelationInput);
+inputComponents.set('select-form-input', SelectWithOptions);
+inputComponents.set('product-multi-form-input', ProductMultiInput);
+inputComponents.set('combination-mode-form-input', CombinationModeInput);
 
-export function getInputComponent(id: string): DataInputComponent | undefined {
-    return globalRegistry.get('inputComponents').get(id);
+export function getInputComponent(id: string | undefined): DashboardFormComponent | undefined {
+    if (!id) {
+        return undefined;
+    }
+    const inputComponent = globalRegistry.get('inputComponents').get(id);
+    return inputComponent;
 }
 
 /**
@@ -58,7 +60,7 @@ export function addInputComponent({
     pageId: string;
     blockId: string;
     field: string;
-    component: DataInputComponent;
+    component: DashboardFormComponent;
 }) {
     const inputComponents = globalRegistry.get('inputComponents');
 
@@ -71,3 +73,19 @@ export function addInputComponent({
     }
     inputComponents.set(key, component);
 }
+
+export function addCustomFieldInputComponent({
+    id,
+    component,
+}: {
+    id: string;
+    component: DashboardFormComponent;
+}) {
+    const inputComponents = globalRegistry.get('inputComponents');
+
+    if (inputComponents.has(id)) {
+        // eslint-disable-next-line no-console
+        console.warn(`Input component with key "${id}" is already registered and will be overwritten.`);
+    }
+    inputComponents.set(id, component);
+}

+ 4 - 27
packages/dashboard/src/lib/framework/extension-api/logic/data-table.ts

@@ -4,28 +4,6 @@ import { addBulkAction, addListQueryDocument } from '../../data-table/data-table
 import { addDisplayComponent } from '../display-component-extensions.js';
 import { DashboardDataTableExtensionDefinition } from '../types/index.js';
 
-/**
- * @description
- * Generates a data table display component key based on the pageId and column name.
- * Uses the pattern: pageId_columnName
- */
-export function generateDataTableDisplayComponentKey(pageId: string, column: string): string {
-    return `${pageId}_${column}`;
-}
-
-/**
- * @description
- * Adds a display component for a specific column in a data table.
- */
-export function addDataTableDisplayComponent(
-    pageId: string,
-    column: string,
-    component: React.ComponentType<{ value: any; [key: string]: any }>,
-) {
-    const key = generateDataTableDisplayComponentKey(pageId, column);
-    addDisplayComponent({ pageId, blockId: 'list-table', field: column, component });
-}
-
 export function registerDataTableExtensions(dataTables?: DashboardDataTableExtensionDefinition[]) {
     if (dataTables) {
         for (const dataTable of dataTables) {
@@ -48,11 +26,10 @@ export function registerDataTableExtensions(dataTables?: DashboardDataTableExten
             }
             if (dataTable.displayComponents?.length) {
                 for (const displayComponent of dataTable.displayComponents) {
-                    addDataTableDisplayComponent(
-                        dataTable.pageId,
-                        displayComponent.column,
-                        displayComponent.component,
-                    );
+                    const blockId = dataTable.blockId ?? 'list-table';
+                    const { pageId } = dataTable;
+                    const { column, component } = displayComponent;
+                    addDisplayComponent({ pageId, blockId, field: column, component });
                 }
             }
         }

+ 3 - 2
packages/dashboard/src/lib/framework/extension-api/logic/form-components.ts

@@ -1,4 +1,5 @@
-import { addCustomFormComponent } from '../../form-engine/custom-form-component-extensions.js';
+import { addCustomFieldInputComponent } from '@/vdb/framework/extension-api/input-component-extensions.js';
+
 import { DashboardCustomFormComponents } from '../types/form-components.js';
 
 export function registerFormComponentExtensions(customFormComponents?: DashboardCustomFormComponents) {
@@ -6,7 +7,7 @@ export function registerFormComponentExtensions(customFormComponents?: Dashboard
         // Handle custom field components
         if (customFormComponents.customFields) {
             for (const component of customFormComponents.customFields) {
-                addCustomFormComponent(component);
+                addCustomFieldInputComponent(component);
             }
         }
     }

+ 2 - 38
packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts

@@ -1,7 +1,4 @@
-import {
-    DataDisplayComponent,
-    DataInputComponent,
-} from '@/vdb/framework/component-registry/component-registry.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { DocumentNode } from 'graphql';
 
 /**
@@ -29,35 +26,7 @@ export interface DashboardDetailFormInputComponent {
      * The React component that will be rendered as the input.
      * It should accept `value`, `onChange`, and other standard input props.
      */
-    component: DataInputComponent;
-}
-
-/**
- * @description
- * Allows you to define custom display components for specific fields in detail forms.
- * The pageId is already defined in the detail form extension, so only the blockId and field are needed.
- *
- * @docsCategory extensions
- * @docsPage DetailForms
- * @since 3.4.0
- */
-export interface DashboardDetailFormDisplayComponent {
-    /**
-     * @description
-     * The ID of the block where this display component should be used.
-     */
-    blockId: string;
-    /**
-     * @description
-     * The name of the field where this display component should be used.
-     */
-    field: string;
-    /**
-     * @description
-     * The React component that will be rendered as the display.
-     * It should accept `value` and other standard display props.
-     */
-    component: DataDisplayComponent;
+    component: DashboardFormComponent;
 }
 
 /**
@@ -86,9 +55,4 @@ export interface DashboardDetailFormExtensionDefinition {
      * Custom input components for specific fields in the detail form.
      */
     inputs?: DashboardDetailFormInputComponent[];
-    /**
-     * @description
-     * Custom display components for specific fields in the detail form.
-     */
-    displays?: DashboardDetailFormDisplayComponent[];
 }

+ 2 - 4
packages/dashboard/src/lib/framework/extension-api/types/form-components.ts

@@ -1,6 +1,4 @@
-import type React from 'react';
-
-import { CustomFormComponentInputProps } from '../../form-engine/custom-form-component.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 
 /**
  * @description
@@ -21,7 +19,7 @@ export interface DashboardCustomFormComponent {
      * @description
      * The React component that will be rendered as the custom form input.
      */
-    component: React.FunctionComponent<CustomFormComponentInputProps>;
+    component: DashboardFormComponent;
 }
 
 /**

+ 0 - 23
packages/dashboard/src/lib/framework/form-engine/custom-form-component-extensions.ts

@@ -1,32 +1,9 @@
 import { DocumentNode } from 'graphql';
 
-import { DashboardCustomFormComponent } from '../extension-api/extension-api-types.js';
 import { globalRegistry } from '../registry/global-registry.js';
 
-import { CustomFormComponentInputProps } from './custom-form-component.js';
-
-globalRegistry.register(
-    'customFormComponents',
-    new Map<string, React.FunctionComponent<CustomFormComponentInputProps>>(),
-);
-
 globalRegistry.register('detailQueryDocumentRegistry', new Map<string, DocumentNode[]>());
 
-export function getCustomFormComponent(
-    id: string,
-): React.FunctionComponent<CustomFormComponentInputProps> | undefined {
-    return globalRegistry.get('customFormComponents').get(id);
-}
-
-export function addCustomFormComponent({ id, component }: DashboardCustomFormComponent) {
-    const customFormComponents = globalRegistry.get('customFormComponents');
-    if (customFormComponents.has(id)) {
-        // eslint-disable-next-line no-console
-        console.warn(`Custom form component with id "${id}" is already registered and will be overwritten.`);
-    }
-    customFormComponents.set(id, component);
-}
-
 export function getDetailQueryDocuments(pageId: string): DocumentNode[] {
     return globalRegistry.get('detailQueryDocumentRegistry').get(pageId) || [];
 }

+ 8 - 25
packages/dashboard/src/lib/framework/form-engine/custom-form-component.tsx

@@ -1,33 +1,16 @@
-import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
-import {
-    ControllerFieldState,
-    ControllerRenderProps,
-    FieldPath,
-    FieldValues,
-    UseFormStateReturn,
-} from 'react-hook-form';
-import { getCustomFormComponent } from './custom-form-component-extensions.js';
+import { getInputComponent } from '@/vdb/framework/extension-api/input-component-extensions.js';
 
-export interface CustomFormComponentProps {
-    fieldProps: CustomFormComponentInputProps;
-    fieldDef: Pick<CustomFieldConfig, 'ui' | 'type' | 'name'>;
-}
-
-export interface CustomFormComponentInputProps<
-    TFieldValues extends FieldValues = FieldValues,
-    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
-> {
-    field: ControllerRenderProps<TFieldValues, TName>;
-    fieldState: ControllerFieldState;
-    formState: UseFormStateReturn<TFieldValues>;
-}
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 
-export function CustomFormComponent({ fieldDef, fieldProps }: CustomFormComponentProps) {
-    const Component = getCustomFormComponent(fieldDef.ui?.component);
+export function CustomFormComponent(props: DashboardFormComponentProps) {
+    if (!props.fieldDef) {
+        return null;
+    }
+    const Component = getInputComponent(props.fieldDef.ui?.component);
 
     if (!Component) {
         return null;
     }
 
-    return <Component {...fieldProps} />;
+    return <Component {...props} />;
 }

+ 35 - 0
packages/dashboard/src/lib/framework/form-engine/default-input-for-type.tsx

@@ -0,0 +1,35 @@
+import { BooleanInput } from '@/vdb/components/data-input/boolean-input.js';
+import { DefaultRelationInput } from '@/vdb/components/data-input/default-relation-input.js';
+import { DateTimeInput, SelectWithOptions } from '@/vdb/components/data-input/index.js';
+import { NumberInput } from '@/vdb/components/data-input/number-input.js';
+import { TextInput } from '@/vdb/components/data-input/text-input.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isStringFieldWithOptions } from '@/vdb/framework/form-engine/utils.js';
+
+/**
+ * Consolidated input component for rendering form inputs based on field type
+ * This replaces the duplicate implementations in custom fields and config args
+ */
+export function DefaultInputForType({ fieldDef, ...fieldProps }: Readonly<DashboardFormComponentProps>) {
+    const type = fieldDef?.type;
+    switch (type) {
+        case 'int':
+        case 'float':
+            return <NumberInput {...fieldProps} fieldDef={fieldDef} />;
+        case 'boolean':
+            return <BooleanInput {...fieldProps} fieldDef={fieldDef} />;
+        case 'datetime':
+            return <DateTimeInput {...fieldProps} fieldDef={fieldDef} />;
+        case 'relation':
+            return <DefaultRelationInput {...fieldProps} fieldDef={fieldDef} />;
+        case 'string': {
+            if (fieldDef && isStringFieldWithOptions(fieldDef)) {
+                return <SelectWithOptions {...fieldProps} fieldDef={fieldDef} />;
+            } else {
+                return <TextInput {...fieldProps} fieldDef={fieldDef} />;
+            }
+        }
+        default:
+            return <TextInput {...fieldProps} fieldDef={fieldDef} />;
+    }
+}

+ 192 - 0
packages/dashboard/src/lib/framework/form-engine/form-control-adapter.tsx

@@ -0,0 +1,192 @@
+import { JSX, useMemo } from 'react';
+import { ControllerRenderProps } from 'react-hook-form';
+
+import { CustomFieldListInput } from '@/vdb/components/data-input/custom-field-list-input.js';
+import { StructFormInput } from '@/vdb/components/data-input/struct-form-input.js';
+import { ConfigurableOperationListInput } from '../../components/data-input/configurable-operation-list-input.js';
+
+import { getInputComponent } from '@/vdb/framework/extension-api/input-component-extensions.js';
+import {
+    ConfigurableFieldDef,
+    DashboardFormComponent,
+} from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isCustomFieldConfig } from '@/vdb/framework/form-engine/utils.js';
+import { DefaultInputForType } from './default-input-for-type.js';
+import { transformValue, ValueMode } from './value-transformers.js';
+
+export interface FormControlAdapterProps {
+    fieldDef: ConfigurableFieldDef;
+    field: ControllerRenderProps<any, any>;
+    valueMode: ValueMode;
+}
+
+/**
+ * Gets the default value for list inputs based on field type
+ */
+function getDefaultValueForType(fieldType: string): any {
+    switch (fieldType) {
+        case 'string':
+        case 'localeString':
+        case 'localeText':
+            return '';
+        case 'int':
+        case 'float':
+            return 0;
+        case 'boolean':
+            return false;
+        default:
+            return '';
+    }
+}
+
+/**
+ * Validates if a custom component is correctly configured for list fields
+ */
+function validateCustomComponent(
+    CustomComponent: any,
+    componentId: string,
+    fieldName: string,
+    isList: boolean,
+): void {
+    if (!CustomComponent.metadata?.isListInput || CustomComponent.metadata.isListInput === 'dynamic') {
+        return;
+    }
+
+    const isConfiguredForList = CustomComponent.metadata.isListInput === true;
+    if (isConfiguredForList !== isList) {
+        // eslint-disable-next-line no-console
+        console.warn([
+            `Custom component ${componentId} is not correctly configured for the ${fieldName} field:`,
+            `The component ${isConfiguredForList ? 'is' : 'is not'} configured as a list input, but the field ${isList ? 'is' : 'is not'} a list field.`,
+        ]);
+    }
+}
+
+/**
+ * Determines if a custom component can be used for the given field configuration
+ */
+function canUseCustomComponent(
+    CustomComponent: DashboardFormComponent | undefined,
+    isList: boolean,
+): CustomComponent is DashboardFormComponent {
+    if (!CustomComponent) return false;
+
+    const listInputMode = CustomComponent.metadata?.isListInput;
+
+    // Dynamic components can handle both list and non-list
+    if (listInputMode === 'dynamic') return true;
+
+    // Exact match: both are list or both are non-list
+    return (isList && listInputMode === true) || (!isList && listInputMode !== true);
+}
+
+/**
+ * Renders struct field inputs
+ */
+function renderStructField(
+    fieldDef: ConfigurableFieldDef,
+    field: ControllerRenderProps<any, any>,
+    fieldWithTransform: ControllerRenderProps<any, any>,
+    isList: boolean,
+    isReadonly: boolean,
+): JSX.Element {
+    if (isList) {
+        return (
+            <CustomFieldListInput
+                {...field}
+                disabled={isReadonly}
+                renderInput={(index, inputField) => (
+                    <StructFormInput {...fieldWithTransform} fieldDef={fieldDef} />
+                )}
+                defaultValue={{}}
+            />
+        );
+    }
+    return <StructFormInput {...fieldWithTransform} fieldDef={fieldDef} />;
+}
+
+/**
+ * Renders list field inputs
+ */
+function renderListField(
+    fieldDef: ConfigurableFieldDef,
+    field: ControllerRenderProps<any, any>,
+    fieldWithTransform: ControllerRenderProps<any, any>,
+    valueMode: ValueMode,
+    isReadonly: boolean,
+): JSX.Element {
+    if (valueMode === 'json-string') {
+        return <ConfigurableOperationListInput {...fieldWithTransform} fieldDef={fieldDef} />;
+    }
+
+    if (fieldDef.type === 'relation') {
+        return <DefaultInputForType {...fieldWithTransform} fieldDef={fieldDef} />;
+    }
+
+    if (fieldDef.type === 'string') {
+        return <DefaultInputForType {...fieldWithTransform} fieldDef={fieldDef} />;
+    }
+
+    return (
+        <CustomFieldListInput
+            {...field}
+            disabled={isReadonly}
+            renderInput={(index, inputField) => <DefaultInputForType {...inputField} fieldDef={fieldDef} />}
+            defaultValue={getDefaultValueForType(fieldDef.type)}
+        />
+    );
+}
+
+/**
+ * This is a wrapper component around the final DashboardFormComponent instances.
+ *
+ * It is responsible for ensuring the correct props get passed to the final component,
+ * and for handling differences between form control use between:
+ *
+ * - Auto-generated forms
+ * - Custom field forms
+ * - Configurable operation forms
+ */
+export function FormControlAdapter({ fieldDef, field, valueMode }: Readonly<FormControlAdapterProps>) {
+    const isList = fieldDef.list ?? false;
+    const isReadonly = isCustomFieldConfig(fieldDef) ? fieldDef.readonly === true : false;
+    const componentId = fieldDef.ui?.component as string | undefined;
+
+    const fieldWithTransform = useMemo(() => {
+        const fieldOnChange = field.onChange.bind(field);
+        const transformedField: FormControlAdapterProps['field'] = {
+            ...field,
+            value: transformValue(field.value, fieldDef, valueMode, 'parse'),
+            onChange: (newValue: any) => {
+                const serializedValue = transformValue(newValue, fieldDef, valueMode, 'serialize');
+                fieldOnChange(serializedValue);
+            },
+        };
+        return transformedField;
+    }, [field.name, field.value, field.onChange, fieldDef, valueMode]);
+
+    const CustomComponent = getInputComponent(componentId);
+
+    // Try to use custom component if available and compatible
+    if (canUseCustomComponent(CustomComponent, isList)) {
+        return <CustomComponent {...fieldWithTransform} fieldDef={fieldDef} />;
+    }
+
+    // Validate custom component configuration for debugging
+    if (CustomComponent) {
+        validateCustomComponent(CustomComponent, componentId!, fieldDef.name, isList);
+    }
+
+    // Handle struct fields (custom fields mode only)
+    if (fieldDef.type === 'struct' && valueMode === 'native') {
+        return renderStructField(fieldDef, field, fieldWithTransform, isList, isReadonly);
+    }
+
+    // Handle list fields
+    if (isList) {
+        return renderListField(fieldDef, field, fieldWithTransform, valueMode, isReadonly);
+    }
+
+    // Default case: non-list, non-struct fields
+    return <DefaultInputForType {...fieldWithTransform} fieldDef={fieldDef} />;
+}

+ 163 - 0
packages/dashboard/src/lib/framework/form-engine/form-engine-types.ts

@@ -0,0 +1,163 @@
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
+import {
+    allCustomFieldsFragment,
+    booleanCustomFieldFragment,
+    customFieldConfigFragment,
+    dateTimeCustomFieldFragment,
+    floatCustomFieldFragment,
+    intCustomFieldFragment,
+    localeStringCustomFieldFragment,
+    localeTextCustomFieldFragment,
+    relationCustomFieldFragment,
+    stringCustomFieldFragment,
+    structCustomFieldFragment,
+    textCustomFieldFragment,
+} from '@/vdb/providers/server-config.js';
+import { ResultOf } from 'gql.tada';
+import React from 'react';
+import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
+
+// Base custom field config
+export type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
+
+// Individual custom field type configurations
+export type StringCustomFieldConfig = ResultOf<typeof stringCustomFieldFragment>;
+export type LocaleStringCustomFieldConfig = ResultOf<typeof localeStringCustomFieldFragment>;
+export type TextCustomFieldConfig = ResultOf<typeof textCustomFieldFragment>;
+export type LocaleTextCustomFieldConfig = ResultOf<typeof localeTextCustomFieldFragment>;
+export type BooleanCustomFieldConfig = ResultOf<typeof booleanCustomFieldFragment>;
+export type IntCustomFieldConfig = ResultOf<typeof intCustomFieldFragment>;
+export type FloatCustomFieldConfig = ResultOf<typeof floatCustomFieldFragment>;
+export type DateTimeCustomFieldConfig = ResultOf<typeof dateTimeCustomFieldFragment>;
+export type RelationCustomFieldConfig = ResultOf<typeof relationCustomFieldFragment>;
+export type StructCustomFieldConfig = ResultOf<typeof structCustomFieldFragment>;
+
+// Union type of all custom field configs
+export type AllCustomFieldConfigs = ResultOf<typeof allCustomFieldsFragment>;
+
+// Configurable operation argument definition
+export type ConfigurableArgDef = ResultOf<typeof configurableOperationDefFragment>['args'][number];
+
+// Union type for all field definitions
+export type ConfigurableFieldDef = AllCustomFieldConfigs | ConfigurableArgDef;
+
+// Struct field types (used within struct custom fields)
+export type StructField = StructCustomFieldConfig['fields'][number];
+
+// Individual struct field type configurations (for type guards)
+export type StringStructField = Extract<StructField, { type: 'string' }>;
+export type IntStructField = Extract<StructField, { type: 'int' }>;
+export type FloatStructField = Extract<StructField, { type: 'float' }>;
+export type BooleanStructField = Extract<StructField, { type: 'boolean' }>;
+export type DateTimeStructField = Extract<StructField, { type: 'datetime' }>;
+
+/**
+ * @description
+ * Props that get passed to all form input components. They are based on the
+ * controller props used by the underlying `react-hook-form`, i.e.:
+ *
+ * ```ts
+ * export type ControllerRenderProps = {
+ *     onChange: (event: any) => void;
+ *     onBlur: () => void;
+ *     value: any;
+ *     disabled?: boolean;
+ *     name: string;
+ *     ref: RefCallBack;
+ * };
+ * ```
+ *
+ * in addition, they can optionally be passed a `fieldDef` prop if the
+ * component is used in the context of a custom field or configurable operation arg.
+ *
+ * The `fieldDef` arg, when present, has the following shape:
+ *
+ * ```ts
+ * export type ConfigurableArgDef = {
+ *     defaultValue: any
+ *     description: string | null
+ *     label: string | null
+ *     list: boolean
+ *     name: string
+ *     required: boolean
+ *     type: string
+ *     ui: any
+ * }
+ * ```
+ *
+ * @docsCategory forms
+ * @docsPage DashboardFormComponent
+ */
+export type DashboardFormComponentProps<
+    TFieldValues extends FieldValues = FieldValues,
+    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+> = ControllerRenderProps<TFieldValues, TName> & {
+    fieldDef?: ConfigurableFieldDef;
+};
+
+/**
+ * @description
+ * Metadata which can be defined on a {@link DashboardFormComponent} which
+ * provides additional information about how the dashboard should render the
+ * component.
+ *
+ * The metadata is defined by adding the static property on the component:
+ *
+ * @example
+ * ```ts
+ * export const MyCustomInput: DashboardFormComponent = props => {
+ *   // implementation omitted
+ * }
+ *
+ * // highlight-start
+ * MyCustomInput.metadata = {
+ *   isListInput: true
+ * }
+ * // highlight-end
+ * ```
+ *
+ * @docsCategory forms
+ * @docsPage DashboardFormComponent
+ */
+export type DashboardFormComponentMetadata = {
+    /**
+     * @description
+     * Defines whether this form component is designed to handle list inputs.
+     * If set to `'dynamic'`, it means the component has internal logic that can
+     * handle both lists and single values.
+     */
+    isListInput?: boolean | 'dynamic';
+    isFullWidth?: boolean;
+};
+
+/**
+ * @description
+ * This is the common type for all custom form components registered for:
+ *
+ * - custom fields
+ * - configurable operation args
+ * - detail page fields
+ *
+ * Here's a simple example:
+ *
+ * ```ts
+ * import { DashboardFormComponent, Input } from '\@vendure/dashboard';
+ *
+ * const MyComponent: DashboardFormComponent = (props) => {
+ *     return <Input value={props.value}
+ *                   onChange={props.onChange}
+ *                   onBlur={props.onBlur}
+ *                   name={props.name}
+ *                   disabled={props.disabled}
+ *                   ref={props.ref}
+ *                   />;
+ * };
+ * ```
+ *
+ * @docsCategory forms
+ * @docsPage DashboardFormComponent
+ * @docsWeight 0
+ */
+export type DashboardFormComponent = React.ComponentType<DashboardFormComponentProps> & {
+    metadata?: DashboardFormComponentMetadata;
+};

+ 55 - 71
packages/dashboard/src/lib/framework/form-engine/form-schema-tools.ts

@@ -3,33 +3,24 @@ 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 {
+    CustomFieldConfig,
+    DateTimeCustomFieldConfig,
+    FloatCustomFieldConfig,
+    IntCustomFieldConfig,
+    StringCustomFieldConfig,
+    StructCustomFieldConfig,
+    StructField,
+} from '@/vdb/framework/form-engine/form-engine-types.js';
 import { z, ZodRawShape, ZodType, ZodTypeAny } from 'zod';
 
-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,
+function mapGraphQLCustomFieldToConfig(field: StructField) {
+    const { __typename, ...rest } = field;
+    const baseConfig: CustomFieldConfig = {
+        ...rest,
         list: field.list ?? false,
+        readonly: false,
+        requiresPermission: [],
         nullable: true, // Default to true since GraphQL fields are nullable by default
     };
 
@@ -37,26 +28,34 @@ function mapGraphQLCustomFieldToConfig(field: StructFieldConfig): CustomFieldCon
         case 'StringStructFieldConfig':
             return {
                 ...baseConfig,
-                pattern: field.pattern ?? undefined,
-            };
+                __typename: 'StringCustomFieldConfig',
+                pattern: field.pattern ?? null,
+                options: [],
+            } satisfies StringCustomFieldConfig;
         case 'IntStructFieldConfig':
             return {
                 ...baseConfig,
-                intMin: field.intMin ?? undefined,
-                intMax: field.intMax ?? undefined,
-            };
+                __typename: 'IntCustomFieldConfig',
+                intMin: field.intMin ?? null,
+                intMax: field.intMax ?? null,
+                intStep: field.intStep ?? null,
+            } satisfies IntCustomFieldConfig;
         case 'FloatStructFieldConfig':
             return {
                 ...baseConfig,
-                floatMin: field.floatMin ?? undefined,
-                floatMax: field.floatMax ?? undefined,
-            };
+                __typename: 'FloatCustomFieldConfig',
+                floatMin: field.floatMin ?? null,
+                floatMax: field.floatMax ?? null,
+                floatStep: field.floatStep ?? null,
+            } satisfies FloatCustomFieldConfig;
         case 'DateTimeStructFieldConfig':
             return {
                 ...baseConfig,
-                datetimeMin: field.datetimeMin ?? undefined,
-                datetimeMax: field.datetimeMax ?? undefined,
-            };
+                __typename: 'DateTimeCustomFieldConfig',
+                datetimeMin: field.datetimeMin ?? null,
+                datetimeMax: field.datetimeMax ?? null,
+                datetimeStep: field.datetimeStep ?? null,
+            } satisfies DateTimeCustomFieldConfig;
         default:
             return baseConfig;
     }
@@ -116,7 +115,7 @@ function createDateValidationSchema(minDate: Date | undefined, maxDate: Date | u
  * @param pattern - Optional regex pattern string for validation
  * @returns Zod string schema with optional pattern validation
  */
-function createStringValidationSchema(pattern?: string): ZodType {
+function createStringValidationSchema(pattern?: string | null): ZodType {
     let schema = z.string();
     if (pattern) {
         schema = schema.regex(new RegExp(pattern), {
@@ -134,30 +133,7 @@ function createStringValidationSchema(pattern?: string): ZodType {
  * @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 != null) {
-        schema = schema.min(min, {
-            message: `Value must be at least ${min}`,
-        });
-    }
-    if (max != null) {
-        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 {
+function createNumberValidationSchema(min?: number | null, max?: number | null): ZodType {
     let schema = z.number();
     if (min != null) {
         schema = schema.min(min, {
@@ -187,17 +163,23 @@ function createCustomFieldValidationSchema(customField: CustomFieldConfig): ZodT
         case 'localeString':
         case 'localeText':
         case 'string':
-            zodType = createStringValidationSchema(customField.pattern);
+            zodType = createStringValidationSchema((customField as StringCustomFieldConfig).pattern);
             break;
         case 'int':
-            zodType = createIntValidationSchema(customField.intMin, customField.intMax);
+            zodType = createNumberValidationSchema(
+                (customField as IntCustomFieldConfig).intMin,
+                (customField as IntCustomFieldConfig).intMax,
+            );
             break;
         case 'float':
-            zodType = createFloatValidationSchema(customField.floatMin, customField.floatMax);
+            zodType = createNumberValidationSchema(
+                (customField as FloatCustomFieldConfig).floatMin,
+                (customField as FloatCustomFieldConfig).floatMax,
+            );
             break;
         case 'datetime': {
-            const minDate = parseDate(customField.datetimeMin);
-            const maxDate = parseDate(customField.datetimeMax);
+            const minDate = parseDate((customField as DateTimeCustomFieldConfig).datetimeMin);
+            const maxDate = parseDate((customField as DateTimeCustomFieldConfig).datetimeMax);
             zodType = createDateValidationSchema(minDate, maxDate);
             break;
         }
@@ -227,7 +209,7 @@ function createStructFieldSchema(structFieldConfig: StructCustomFieldConfig): Zo
 
     const nestedSchema: ZodRawShape = {};
     for (const structSubField of structFieldConfig.fields) {
-        const config = mapGraphQLCustomFieldToConfig(structSubField as StructFieldConfig);
+        const config = mapGraphQLCustomFieldToConfig(structSubField);
         let subFieldType = createCustomFieldValidationSchema(config);
 
         // Handle list and nullable for struct sub-fields
@@ -253,7 +235,7 @@ function createStructFieldSchema(structFieldConfig: StructCustomFieldConfig): Zo
  * @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 {
+function applyCustomFieldModifiers(zodType: ZodType, customField: CustomFieldConfig): ZodType {
     let modifiedType = zodType;
 
     if (customField.list) {
@@ -262,7 +244,9 @@ function applyListAndNullableModifiers(zodType: ZodType, customField: CustomFiel
     if (customField.nullable !== false) {
         modifiedType = modifiedType.optional().nullable();
     }
-
+    if (customField.readonly) {
+        modifiedType = modifiedType.readonly();
+    }
     return modifiedType;
 }
 
@@ -299,7 +283,7 @@ function processCustomFieldsSchema(
             zodType = createCustomFieldValidationSchema(customField);
         }
 
-        zodType = applyListAndNullableModifiers(zodType, customField);
+        zodType = applyCustomFieldModifiers(zodType, customField);
         const schemaPropertyName = getGraphQlInputName(customField);
         customFieldsSchema[schemaPropertyName] = zodType;
     }
@@ -319,7 +303,7 @@ export function createFormSchemaFromFields(
         const isEnum = isEnumType(field.type);
 
         if ((isScalar || isEnum) && field.name !== 'customFields') {
-            schemaConfig[field.name] = getZodTypeFromField(field, customFieldConfigs);
+            schemaConfig[field.name] = getZodTypeFromField(field);
         } else if (field.name === 'customFields') {
             const customFieldsSchema =
                 customFieldConfigs && customFieldConfigs.length > 0
@@ -391,7 +375,7 @@ export function getDefaultValueFromField(field: FieldInfo, defaultLanguageCode?:
     }
 }
 
-export function getZodTypeFromField(field: FieldInfo, customFieldConfigs?: CustomFieldConfig[]): ZodTypeAny {
+export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
     let zodType: ZodType;
 
     // This function is only used for non-custom fields, so we don't need custom field logic here

+ 2 - 2
packages/dashboard/src/lib/framework/form-engine/overridden-form-component.tsx

@@ -1,9 +1,9 @@
 import {
     DataDisplayComponent,
-    DataInputComponent,
     useComponentRegistry,
 } from '@/vdb/framework/component-registry/component-registry.js';
 import { generateInputComponentKey } from '@/vdb/framework/extension-api/input-component-extensions.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { usePageBlock } from '@/vdb/hooks/use-page-block.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
@@ -34,7 +34,7 @@ export function OverriddenFormComponent({ fieldName, field, children }: Readonly
     const pageBlock = usePageBlock({ optional: true });
     const componentRegistry = useComponentRegistry();
     let DisplayComponent: DataDisplayComponent | undefined;
-    let InputComponent: DataInputComponent | undefined;
+    let InputComponent: DashboardFormComponent | undefined;
     if (page.pageId && pageBlock?.blockId) {
         const customInputComponentKey = generateInputComponentKey(page.pageId, pageBlock.blockId, fieldName);
         DisplayComponent = componentRegistry.getDisplayComponent(customInputComponentKey);

+ 223 - 0
packages/dashboard/src/lib/framework/form-engine/utils.ts

@@ -1,3 +1,25 @@
+import {
+    AllCustomFieldConfigs,
+    BooleanCustomFieldConfig,
+    BooleanStructField,
+    ConfigurableArgDef,
+    ConfigurableFieldDef,
+    DateTimeCustomFieldConfig,
+    DateTimeStructField,
+    FloatCustomFieldConfig,
+    FloatStructField,
+    IntCustomFieldConfig,
+    IntStructField,
+    LocaleStringCustomFieldConfig,
+    LocaleTextCustomFieldConfig,
+    RelationCustomFieldConfig,
+    StringCustomFieldConfig,
+    StringStructField,
+    StructCustomFieldConfig,
+    StructField,
+    TextCustomFieldConfig,
+} from '@/vdb/framework/form-engine/form-engine-types.js';
+
 import { FieldInfo } from '../document-introspection/get-document-structure.js';
 
 /**
@@ -97,3 +119,204 @@ export function removeEmptyIdFields<T extends Record<string, any>>(values: T, fi
     recursiveRemove(result, fields);
     return result;
 }
+
+// =============================================================================
+// TYPE GUARDS FOR CONFIGURABLE FIELD DEFINITIONS
+// =============================================================================
+
+/**
+ * Determines if a field definition is a custom field config (vs configurable operation arg)
+ */
+export function isCustomFieldConfig(input: ConfigurableFieldDef): input is AllCustomFieldConfigs {
+    return input.hasOwnProperty('readonly');
+}
+
+/**
+ * Determines if a field definition is a configurable operation argument
+ */
+export function isConfigurableArgDef(input: ConfigurableFieldDef): input is ConfigurableArgDef {
+    return !input.hasOwnProperty('readonly');
+}
+
+// =============================================================================
+// TYPE GUARDS FOR SPECIFIC CUSTOM FIELD TYPES
+// =============================================================================
+
+/**
+ * String custom field with optional pattern and options
+ */
+export function isStringCustomFieldConfig(input: ConfigurableFieldDef): input is StringCustomFieldConfig {
+    return input.type === 'string' && isCustomFieldConfig(input);
+}
+
+/**
+ * String custom field that has options (select dropdown)
+ */
+export function isStringFieldWithOptions(input: ConfigurableFieldDef): input is StringCustomFieldConfig {
+    const isCustomFieldWithOptions =
+        input.type === 'string' &&
+        isCustomFieldConfig(input) &&
+        input.hasOwnProperty('options') &&
+        Array.isArray((input as any).options);
+    if (isCustomFieldWithOptions) {
+        return true;
+    }
+    const isConfigArgWithOptions =
+        input.type === 'string' && isConfigurableArgDef(input) && Array.isArray(input.ui?.options);
+    if (isConfigArgWithOptions) {
+        return true;
+    }
+    return false;
+}
+
+/**
+ * Locale string custom field
+ */
+export function isLocaleStringCustomFieldConfig(
+    input: ConfigurableFieldDef,
+): input is LocaleStringCustomFieldConfig {
+    return input.type === 'localeString' && isCustomFieldConfig(input);
+}
+
+/**
+ * Text custom field (textarea)
+ */
+export function isTextCustomFieldConfig(input: ConfigurableFieldDef): input is TextCustomFieldConfig {
+    return input.type === 'text' && isCustomFieldConfig(input);
+}
+
+/**
+ * Locale text custom field (localized textarea)
+ */
+export function isLocaleTextCustomFieldConfig(
+    input: ConfigurableFieldDef,
+): input is LocaleTextCustomFieldConfig {
+    return input.type === 'localeText' && isCustomFieldConfig(input);
+}
+
+/**
+ * Boolean custom field
+ */
+export function isBooleanCustomFieldConfig(input: ConfigurableFieldDef): input is BooleanCustomFieldConfig {
+    return input.type === 'boolean' && isCustomFieldConfig(input);
+}
+
+/**
+ * Integer custom field with optional min/max/step
+ */
+export function isIntCustomFieldConfig(input: ConfigurableFieldDef): input is IntCustomFieldConfig {
+    return input.type === 'int' && isCustomFieldConfig(input);
+}
+
+/**
+ * Float custom field with optional min/max/step
+ */
+export function isFloatCustomFieldConfig(input: ConfigurableFieldDef): input is FloatCustomFieldConfig {
+    return input.type === 'float' && isCustomFieldConfig(input);
+}
+
+/**
+ * DateTime custom field with optional min/max/step
+ */
+export function isDateTimeCustomFieldConfig(input: ConfigurableFieldDef): input is DateTimeCustomFieldConfig {
+    return input.type === 'datetime' && isCustomFieldConfig(input);
+}
+
+/**
+ * Relation custom field (references another entity)
+ */
+export function isRelationCustomFieldConfig(input: ConfigurableFieldDef): input is RelationCustomFieldConfig {
+    return input.type === 'relation' && isCustomFieldConfig(input);
+}
+
+/**
+ * Struct custom field (nested object with sub-fields)
+ */
+export function isStructCustomFieldConfig(input: ConfigurableFieldDef): input is StructCustomFieldConfig {
+    return input.type === 'struct' && isCustomFieldConfig(input);
+}
+
+// Legacy alias for backward compatibility
+export const isStructFieldConfig = isStructCustomFieldConfig;
+
+// =============================================================================
+// TYPE GUARDS FOR STRUCT FIELD TYPES (fields within struct custom fields)
+// =============================================================================
+
+/**
+ * String field within a struct custom field
+ */
+export function isStringStructField(input: StructField): input is StringStructField {
+    return input.type === 'string';
+}
+
+/**
+ * String struct field that has options (select dropdown)
+ */
+export function isStringStructFieldWithOptions(
+    input: StructField,
+): input is StringStructField & { options: any[] } {
+    return (
+        input.type === 'string' && input.hasOwnProperty('options') && Array.isArray((input as any).options)
+    );
+}
+
+/**
+ * Integer field within a struct custom field
+ */
+export function isIntStructField(input: StructField): input is IntStructField {
+    return input.type === 'int';
+}
+
+/**
+ * Float field within a struct custom field
+ */
+export function isFloatStructField(input: StructField): input is FloatStructField {
+    return input.type === 'float';
+}
+
+/**
+ * Boolean field within a struct custom field
+ */
+export function isBooleanStructField(input: StructField): input is BooleanStructField {
+    return input.type === 'boolean';
+}
+
+/**
+ * DateTime field within a struct custom field
+ */
+export function isDateTimeStructField(input: StructField): input is DateTimeStructField {
+    return input.type === 'datetime';
+}
+
+// =============================================================================
+// UTILITY TYPE GUARDS
+// =============================================================================
+
+/**
+ * Determines if a field is a list/array field
+ */
+export function isListField(input?: ConfigurableFieldDef): boolean {
+    return input && isCustomFieldConfig(input) ? Boolean(input.list) : false;
+}
+
+/**
+ * Determines if a field is readonly
+ */
+export function isReadonlyField(input?: ConfigurableFieldDef): boolean {
+    return input && isCustomFieldConfig(input) ? Boolean(input.readonly) : false;
+}
+
+/**
+ * Determines if a field requires special permissions
+ */
+export function hasPermissionRequirement(input: ConfigurableFieldDef): boolean {
+    return isCustomFieldConfig(input) && Boolean(input.requiresPermission);
+}
+
+/**
+ * Determines if a field is nullable
+ */
+export function isNullableField(input: ConfigurableFieldDef): boolean {
+    return isCustomFieldConfig(input) && Boolean(input.nullable);
+}

+ 9 - 9
packages/dashboard/src/lib/components/shared/value-transformers.ts → packages/dashboard/src/lib/framework/form-engine/value-transformers.ts

@@ -1,4 +1,4 @@
-import { UniversalFieldDefinition } from './universal-field-definition.js';
+import { ConfigurableFieldDef } from '@/vdb/framework/form-engine/form-engine-types.js';
 
 export type ValueMode = 'native' | 'json-string';
 
@@ -6,19 +6,19 @@ export type ValueMode = 'native' | 'json-string';
  * Interface for transforming values between native JavaScript types and JSON strings
  */
 export interface ValueTransformer {
-    parse: (value: string, fieldDef: UniversalFieldDefinition) => any;
-    serialize: (value: any, fieldDef: UniversalFieldDefinition) => string;
+    parse: (value: string, fieldDef: ConfigurableFieldDef) => any;
+    serialize: (value: any, fieldDef: ConfigurableFieldDef) => string;
 }
 
 /**
  * Native value transformer - passes values through unchanged
  */
 export const nativeValueTransformer: ValueTransformer = {
-    parse: (value: string, fieldDef: UniversalFieldDefinition) => {
+    parse: (value: string, fieldDef: ConfigurableFieldDef) => {
         // For native mode, values are already in their correct JavaScript type
         return value;
     },
-    serialize: (value: any, fieldDef: UniversalFieldDefinition) => {
+    serialize: (value: any, fieldDef: ConfigurableFieldDef) => {
         // For native mode, values are already in their correct JavaScript type
         return value;
     },
@@ -28,7 +28,7 @@ export const nativeValueTransformer: ValueTransformer = {
  * JSON string value transformer - converts between JSON strings and native values
  */
 export const jsonStringValueTransformer: ValueTransformer = {
-    parse: (value: string, fieldDef: UniversalFieldDefinition) => {
+    parse: (value: string, fieldDef: ConfigurableFieldDef) => {
         if (!value) {
             return getDefaultValue(fieldDef);
         }
@@ -62,7 +62,7 @@ export const jsonStringValueTransformer: ValueTransformer = {
             }
         }
     },
-    serialize: (value: any, fieldDef: UniversalFieldDefinition) => {
+    serialize: (value: any, fieldDef: ConfigurableFieldDef) => {
         if (value === null || value === undefined) {
             return '';
         }
@@ -100,7 +100,7 @@ export function getValueTransformer(valueMode: ValueMode): ValueTransformer {
 /**
  * Get default value for a field type
  */
-function getDefaultValue(fieldDef: UniversalFieldDefinition): any {
+function getDefaultValue(fieldDef: ConfigurableFieldDef): any {
     if (fieldDef.list) {
         return [];
     }
@@ -132,7 +132,7 @@ function getDefaultValue(fieldDef: UniversalFieldDefinition): any {
  */
 export function transformValue(
     value: any,
-    fieldDef: UniversalFieldDefinition,
+    fieldDef: ConfigurableFieldDef,
     valueMode: ValueMode,
     direction: 'parse' | 'serialize',
 ): any {

+ 3 - 5
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -5,12 +5,11 @@ import {
     DashboardPageBlockDefinition,
     DashboardWidgetDefinition,
 } from '@/vdb/framework/extension-api/types/index.js';
+import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { DocumentNode } from 'graphql';
-import React from 'react';
 
-import { DataDisplayComponent, DataInputComponent } from '../component-registry/component-registry.js';
+import { DataDisplayComponent } from '../component-registry/component-registry.js';
 import { DashboardAlertDefinition } from '../extension-api/types/alerts.js';
-import { CustomFormComponentInputProps } from '../form-engine/custom-form-component.js';
 import { NavMenuConfig } from '../nav-menu/nav-menu-extensions.js';
 
 export interface GlobalRegistryContents {
@@ -21,8 +20,7 @@ export interface GlobalRegistryContents {
     dashboardPageBlockRegistry: Map<string, DashboardPageBlockDefinition[]>;
     dashboardWidgetRegistry: Map<string, DashboardWidgetDefinition>;
     dashboardAlertRegistry: Map<string, DashboardAlertDefinition>;
-    customFormComponents: Map<string, React.FunctionComponent<CustomFormComponentInputProps>>;
-    inputComponents: Map<string, DataInputComponent>;
+    inputComponents: Map<string, DashboardFormComponent>;
     displayComponents: Map<string, DataDisplayComponent>;
     bulkActionsRegistry: Map<string, BulkAction[]>;
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;

File diff suppressed because it is too large
+ 0 - 1
packages/dashboard/src/lib/graphql/graphql-env.d.ts


+ 28 - 1
packages/dashboard/src/lib/index.ts

@@ -5,13 +5,26 @@ export * from './components/data-display/date-time.js';
 export * from './components/data-display/json.js';
 export * from './components/data-display/money.js';
 export * from './components/data-input/affixed-input.js';
+export * from './components/data-input/boolean-input.js';
+export * from './components/data-input/checkbox-input.js';
+export * from './components/data-input/combination-mode-input.js';
+export * from './components/data-input/configurable-operation-list-input.js';
+export * from './components/data-input/custom-field-list-input.js';
 export * from './components/data-input/customer-group-input.js';
 export * from './components/data-input/datetime-input.js';
+export * from './components/data-input/default-relation-input.js';
 export * from './components/data-input/facet-value-input.js';
 export * from './components/data-input/money-input.js';
+export * from './components/data-input/number-input.js';
+export * from './components/data-input/password-input.js';
+export * from './components/data-input/product-multi-selector-input.js';
 export * from './components/data-input/relation-input.js';
 export * from './components/data-input/relation-selector.js';
 export * from './components/data-input/rich-text-input.js';
+export * from './components/data-input/select-with-options.js';
+export * from './components/data-input/struct-form-input.js';
+export * from './components/data-input/text-input.js';
+export * from './components/data-input/textarea-input.js';
 export * from './components/data-table/add-filter-menu.js';
 export * from './components/data-table/data-table-bulk-action-item.js';
 export * from './components/data-table/data-table-bulk-actions.js';
@@ -37,8 +50,11 @@ export * from './components/layout/app-layout.js';
 export * from './components/layout/app-sidebar.js';
 export * from './components/layout/channel-switcher.js';
 export * from './components/layout/content-language-selector.js';
+export * from './components/layout/dev-mode-indicator.js';
 export * from './components/layout/generated-breadcrumbs.js';
 export * from './components/layout/language-dialog.js';
+export * from './components/layout/manage-languages-dialog.js';
+export * from './components/layout/nav-item-wrapper.js';
 export * from './components/layout/nav-main.js';
 export * from './components/layout/nav-projects.js';
 export * from './components/layout/nav-user.js';
@@ -62,6 +78,8 @@ export * from './components/shared/channel-code-label.js';
 export * from './components/shared/channel-selector.js';
 export * from './components/shared/configurable-operation-arg-input.js';
 export * from './components/shared/configurable-operation-input.js';
+export * from './components/shared/configurable-operation-multi-selector.js';
+export * from './components/shared/configurable-operation-selector.js';
 export * from './components/shared/confirmation-dialog.js';
 export * from './components/shared/copyable-text.js';
 export * from './components/shared/country-selector.js';
@@ -77,6 +95,7 @@ export * from './components/shared/error-page.js';
 export * from './components/shared/facet-value-chip.js';
 export * from './components/shared/facet-value-selector.js';
 export * from './components/shared/form-field-wrapper.js';
+export * from './components/shared/history-timeline/history-entry-date.js';
 export * from './components/shared/history-timeline/history-entry.js';
 export * from './components/shared/history-timeline/history-note-checkbox.js';
 export * from './components/shared/history-timeline/history-note-editor.js';
@@ -149,7 +168,7 @@ export * from './framework/alert/alert-extensions.js';
 export * from './framework/alert/alert-item.js';
 export * from './framework/alert/alerts-indicator.js';
 export * from './framework/component-registry/component-registry.js';
-export * from './framework/component-registry/dynamic-component.js';
+export * from './framework/component-registry/display-component.js';
 export * from './framework/dashboard-widget/base-widget.js';
 export * from './framework/dashboard-widget/latest-orders-widget/latest-orders-widget.graphql.js';
 export * from './framework/dashboard-widget/metrics-widget/chart.js';
@@ -172,6 +191,7 @@ export * from './framework/extension-api/logic/data-table.js';
 export * from './framework/extension-api/logic/detail-forms.js';
 export * from './framework/extension-api/logic/form-components.js';
 export * from './framework/extension-api/logic/layout.js';
+export * from './framework/extension-api/logic/login.js';
 export * from './framework/extension-api/logic/navigation.js';
 export * from './framework/extension-api/logic/widgets.js';
 export * from './framework/extension-api/types/alerts.js';
@@ -186,10 +206,15 @@ export * from './framework/extension-api/use-dashboard-extensions.js';
 export * from './framework/extension-api/use-login-extensions.js';
 export * from './framework/form-engine/custom-form-component-extensions.js';
 export * from './framework/form-engine/custom-form-component.js';
+export * from './framework/form-engine/default-input-for-type.js';
+export * from './framework/form-engine/form-control-adapter.js';
+export * from './framework/form-engine/form-engine-types.js';
 export * from './framework/form-engine/form-schema-tools.js';
 export * from './framework/form-engine/overridden-form-component.js';
 export * from './framework/form-engine/use-generated-form.js';
 export * from './framework/form-engine/utils.js';
+export * from './framework/form-engine/value-transformers.js';
+export * from './framework/layout-engine/dev-mode-button.js';
 export * from './framework/layout-engine/layout-extensions.js';
 export * from './framework/layout-engine/location-wrapper.js';
 export * from './framework/layout-engine/page-block-provider.js';
@@ -209,11 +234,13 @@ export * from './graphql/api.js';
 export * from './graphql/common-operations.js';
 export * from './graphql/fragments.js';
 export * from './graphql/graphql.js';
+export * from './graphql/settings-store-operations.js';
 export * from './hooks/use-auth.js';
 export * from './hooks/use-channel.js';
 export * from './hooks/use-custom-field-config.js';
 export * from './hooks/use-extended-detail-query.js';
 export * from './hooks/use-extended-list-query.js';
+export * from './hooks/use-floating-bulk-actions.js';
 export * from './hooks/use-grouped-permissions.js';
 export * from './hooks/use-local-format.js';
 export * from './hooks/use-mobile.js';

+ 1 - 0
packages/dashboard/src/lib/providers/server-config.tsx

@@ -124,6 +124,7 @@ export const structCustomFieldFragment = graphql(
             ...CustomFieldConfig
             fields {
                 ... on StructField {
+                    __typename
                     name
                     type
                     list

+ 295 - 0
packages/dev-server/test-plugins/field-test/dashboard/form-components.tsx

@@ -0,0 +1,295 @@
+import {
+    AffixedInput,
+    Badge,
+    Button,
+    Card,
+    CardContent,
+    cn,
+    DashboardFormComponent,
+    Input,
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+    Switch,
+    Textarea,
+    useLocalFormat,
+} from '@vendure/dashboard';
+import { Check, Lock, Mail, RefreshCw, Unlock, X } from 'lucide-react';
+import { KeyboardEvent, useEffect, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+
+export const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => {
+    const [isOpen, setIsOpen] = useState(false);
+    const { getFieldState } = useFormContext();
+    const error = getFieldState(name).error;
+
+    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD'];
+
+    return (
+        <div className="space-y-2">
+            <div className="flex items-center space-x-2">
+                <Button
+                    type="button"
+                    variant="outline"
+                    size="icon"
+                    className={cn('w-8 h-8 border-2 border-gray-300 p-0', error && 'border-red-500')}
+                    style={{ backgroundColor: error ? 'transparent' : value || '#ffffff' }}
+                    onClick={() => setIsOpen(!isOpen)}
+                />
+                <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder="#ffffff" />
+            </div>
+
+            {isOpen && (
+                <Card>
+                    <CardContent className="grid grid-cols-4 gap-2 p-2">
+                        {colors.map(color => (
+                            <Button
+                                key={color}
+                                type="button"
+                                variant="outline"
+                                size="icon"
+                                className="w-8 h-8 border-2 border-gray-300 hover:border-gray-500 p-0"
+                                style={{ backgroundColor: color }}
+                                onClick={() => {
+                                    onChange(color);
+                                    setIsOpen(false);
+                                }}
+                            />
+                        ))}
+                    </CardContent>
+                </Card>
+            )}
+        </div>
+    );
+};
+
+export const MarkdownEditorComponent: DashboardFormComponent = props => {
+    const { getFieldState } = useFormContext();
+    const fieldState = getFieldState(props.name);
+
+    return (
+        <Textarea
+            className="font-mono"
+            ref={props.ref}
+            onBlur={props.onBlur}
+            value={props.value}
+            onChange={e => props.onChange(e.target.value)}
+            disabled={props.disabled}
+        />
+    );
+};
+
+export const EmailInputComponent: DashboardFormComponent = ({ name, value, onChange, disabled }) => {
+    const { getFieldState } = useFormContext();
+    const isValid = getFieldState(name).invalid === false;
+
+    return (
+        <AffixedInput
+            prefix={<Mail className="h-4 w-4 text-muted-foreground" />}
+            suffix={
+                value &&
+                (isValid ? (
+                    <Check className="h-4 w-4 text-green-500" />
+                ) : (
+                    <X className="h-4 w-4 text-red-500" />
+                ))
+            }
+            value={value || ''}
+            onChange={e => onChange(e.target.value)}
+            disabled={disabled}
+            placeholder="Enter email address"
+            className="pl-10 pr-10"
+            name={name}
+        />
+    );
+};
+
+export const MultiCurrencyInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
+    const [currency, setCurrency] = useState('USD');
+    const { formatCurrencyName } = useLocalFormat();
+
+    const currencies = [
+        { code: 'USD', symbol: '$', rate: 1 },
+        { code: 'EUR', symbol: '€', rate: 0.85 },
+        { code: 'GBP', symbol: '£', rate: 0.73 },
+        { code: 'JPY', symbol: '¥', rate: 110 },
+    ];
+
+    const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];
+
+    // Convert price based on exchange rate
+    const displayValue = value ? (value * selectedCurrency.rate).toFixed(2) : '';
+
+    const handleChange = (val: string) => {
+        const numericValue = parseFloat(val) || 0;
+        // Convert back to base currency (USD) for storage
+        const baseValue = numericValue / selectedCurrency.rate;
+        onChange(baseValue);
+    };
+
+    return (
+        <div className="flex space-x-2">
+            <Select value={currency} onValueChange={setCurrency} disabled={disabled}>
+                <SelectTrigger className="w-24">
+                    <SelectValue>
+                        <div className="flex items-center gap-1">{currency}</div>
+                    </SelectValue>
+                </SelectTrigger>
+                <SelectContent>
+                    {currencies.map(curr => {
+                        return (
+                            <SelectItem key={curr.code} value={curr.code}>
+                                <div className="flex items-center gap-2">{formatCurrencyName(curr.code)}</div>
+                            </SelectItem>
+                        );
+                    })}
+                </SelectContent>
+            </Select>
+            <AffixedInput
+                prefix={selectedCurrency.symbol}
+                value={displayValue}
+                onChange={e => onChange(e.target.value)}
+                disabled={disabled}
+                placeholder="0.00"
+                className="pl-8"
+                name={name}
+            />
+        </div>
+    );
+};
+
+export const TagsInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name, onBlur }) => {
+    const [inputValue, setInputValue] = useState('');
+
+    // Parse tags from string value (comma-separated)
+    const tags: string[] = value ? value.split(',').filter(Boolean) : [];
+
+    const addTag = (tag: string) => {
+        const trimmedTag = tag.trim();
+        if (trimmedTag && !tags.includes(trimmedTag)) {
+            const newTags = [...tags, trimmedTag];
+            onChange(newTags.join(','));
+        }
+        setInputValue('');
+    };
+
+    const removeTag = (tagToRemove: string) => {
+        const newTags = tags.filter(tag => tag !== tagToRemove);
+        onChange(newTags.join(','));
+    };
+
+    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
+        if (e.key === 'Enter' || e.key === ',') {
+            e.preventDefault();
+            addTag(inputValue);
+        } else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0) {
+            removeTag(tags[tags.length - 1]);
+        }
+    };
+
+    return (
+        <div className="space-y-2">
+            {/* Tags Display */}
+            <div className="flex flex-wrap gap-1">
+                {tags.map((tag, index) => (
+                    <Badge key={index} variant="secondary" className="gap-1">
+                        {tag}
+                        <Button
+                            type="button"
+                            variant="ghost"
+                            size="icon"
+                            className="h-4 w-4 p-0 hover:bg-transparent"
+                            onClick={() => removeTag(tag)}
+                            disabled={disabled}
+                        >
+                            <X className="h-3 w-3" />
+                        </Button>
+                    </Badge>
+                ))}
+            </div>
+
+            {/* Input */}
+            <Input
+                value={inputValue}
+                onChange={e => setInputValue(e.target.value)}
+                onKeyDown={handleKeyDown}
+                onBlur={onBlur}
+                disabled={disabled}
+                placeholder="Type a tag and press Enter or comma"
+                name={name}
+            />
+        </div>
+    );
+};
+
+export const SlugInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
+    const [autoGenerate, setAutoGenerate] = useState(!value);
+    const [isGenerating, setIsGenerating] = useState(false);
+    const { watch } = useFormContext();
+    const nameValue = watch('translations.0.name');
+
+    const generateSlug = (text: string) => {
+        return text
+            .toLowerCase()
+            .replace(/[^a-z0-9 -]/g, '') // Remove special characters
+            .replace(/\s+/g, '-') // Replace spaces with hyphens
+            .replace(/-+/g, '-') // Replace multiple hyphens with single
+            .trim('-'); // Remove leading/trailing hyphens
+    };
+
+    useEffect(() => {
+        if (autoGenerate && nameValue) {
+            const newSlug = generateSlug(nameValue);
+            if (newSlug !== value) {
+                onChange(newSlug);
+            }
+        }
+    }, [nameValue, autoGenerate, onChange, value]);
+
+    const handleManualGenerate = async () => {
+        if (!nameValue) return;
+
+        setIsGenerating(true);
+        // Simulate API call for slug validation/generation
+        await new Promise(resolve => setTimeout(resolve, 500));
+
+        const newSlug = generateSlug(nameValue);
+        onChange(newSlug);
+        setIsGenerating(false);
+    };
+
+    return (
+        <div className="space-y-2">
+            <div className="flex items-center space-x-2">
+                <Input
+                    value={value || ''}
+                    onChange={e => onChange(e.target.value)}
+                    disabled={disabled || autoGenerate}
+                    placeholder="product-slug"
+                    className="flex-1"
+                    name={name}
+                />
+
+                <Button
+                    type="button"
+                    variant="outline"
+                    size="icon"
+                    disabled={disabled || !nameValue || isGenerating}
+                    onClick={handleManualGenerate}
+                >
+                    <RefreshCw className={`h-4 w-4 ${isGenerating ? 'animate-spin' : ''}`} />
+                </Button>
+            </div>
+
+            <div className="flex items-center space-x-2">
+                <Switch checked={autoGenerate} onCheckedChange={setAutoGenerate} disabled={disabled} />
+                <div className="flex items-center space-x-1 text-sm text-muted-foreground">
+                    {autoGenerate ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
+                    <span>Auto-generate from name</span>
+                </div>
+            </div>
+        </div>
+    );
+};

+ 73 - 0
packages/dev-server/test-plugins/field-test/dashboard/index.tsx

@@ -0,0 +1,73 @@
+import { defineDashboardExtension } from '@vendure/dashboard';
+
+import {
+    ColorPickerComponent,
+    EmailInputComponent,
+    MarkdownEditorComponent,
+    MultiCurrencyInputComponent,
+    SlugInputComponent,
+    TagsInputComponent,
+} from './form-components';
+
+defineDashboardExtension({
+    customFormComponents: {
+        customFields: [
+            {
+                id: 'test-input',
+                component: props => {
+                    return (
+                        <input
+                            placeholder="custom input"
+                            value={props.value || ''}
+                            onChange={e => props.onChange(e.target.value)}
+                            className="border rounded-full"
+                        />
+                    );
+                },
+            },
+            {
+                id: 'color-picker',
+                component: ColorPickerComponent,
+            },
+            {
+                id: 'custom-email',
+                component: EmailInputComponent,
+            },
+            {
+                id: 'multi-currency-input',
+                component: MultiCurrencyInputComponent,
+            },
+            {
+                id: 'tags-input',
+                component: TagsInputComponent,
+            },
+        ],
+    },
+    detailForms: [
+        {
+            pageId: 'product-detail',
+            inputs: [
+                {
+                    blockId: 'main-form',
+                    field: 'slug',
+                    component: SlugInputComponent,
+                },
+                {
+                    blockId: 'main-form',
+                    field: 'description',
+                    component: MarkdownEditorComponent,
+                },
+            ],
+        },
+        {
+            pageId: 'customer-detail',
+            inputs: [
+                {
+                    blockId: 'main-form',
+                    field: 'emailAddress',
+                    component: EmailInputComponent,
+                },
+            ],
+        },
+    ],
+});

+ 45 - 3
packages/dev-server/test-plugins/field-test-plugin.ts → packages/dev-server/test-plugins/field-test/field-test-plugin.ts

@@ -39,9 +39,21 @@ const comprehensiveTestPaymentHandler = new PaymentMethodHandler({
             type: 'string',
             label: [{ languageCode: LanguageCode.en, value: 'Merchant ID' }],
             description: [{ languageCode: LanguageCode.en, value: 'Merchant identifier' }],
-            ui: { component: 'text-form-input' },
+            ui: { component: 'test-input' },
             required: true,
         },
+        color: {
+            type: 'string',
+            label: [{ languageCode: LanguageCode.en, value: 'Color' }],
+            description: [{ languageCode: LanguageCode.en, value: 'Color code for this payment method' }],
+            ui: { component: 'color-picker' },
+        },
+        supplierEmail: {
+            type: 'string',
+            label: [{ languageCode: LanguageCode.en, value: 'Supplier Email' }],
+            pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$',
+            ui: { component: 'custom-email' },
+        },
         environment: {
             type: 'string',
             label: [{ languageCode: LanguageCode.en, value: 'Environment' }],
@@ -236,7 +248,7 @@ const comprehensiveTestPaymentHandler = new PaymentMethodHandler({
             return {
                 amount,
                 state: args.testMode ? 'Authorized' : 'Settled',
-                transactionId: 'test-' + Math.random().toString(36).substr(2, 9),
+                transactionId: 'test-' + Math.random().toString(36).substring(2, 7),
                 metadata: {
                     ...metadata,
                     processingFee: args.processingFee,
@@ -335,6 +347,35 @@ const comprehensiveTestPaymentHandler = new PaymentMethodHandler({
                 description: [{ languageCode: LanguageCode.en, value: 'Custom SKU for this product' }],
                 readonly: true,
             },
+            {
+                name: 'supplierEmail',
+                type: 'string',
+                label: [{ languageCode: LanguageCode.en, value: 'Supplier Email' }],
+                pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$',
+                ui: { component: 'custom-email' },
+            },
+            {
+                name: 'RRP',
+                type: 'int',
+                label: [{ languageCode: LanguageCode.en, value: 'RRP' }],
+                ui: { component: 'multi-currency-input' },
+            },
+            {
+                name: 'simpleTags',
+                type: 'string',
+                label: [{ languageCode: LanguageCode.en, value: 'Product tags' }],
+                ui: { component: 'tags-input' },
+            },
+            {
+                name: 'color',
+                type: 'string',
+                pattern: '^#[A-Fa-f0-9]{6}$',
+                label: [{ languageCode: LanguageCode.en, value: 'Color' }],
+                description: [{ languageCode: LanguageCode.en, value: 'Main color for this product' }],
+                ui: {
+                    component: 'color-picker',
+                },
+            },
             {
                 name: 'category',
                 type: 'string',
@@ -396,7 +437,7 @@ const comprehensiveTestPaymentHandler = new PaymentMethodHandler({
                 type: 'text',
                 label: [{ languageCode: LanguageCode.en, value: 'Specifications' }],
                 description: [{ languageCode: LanguageCode.en, value: 'Product specifications (long text)' }],
-                ui: { fullWidth: true },
+                ui: { fullWidth: false, component: 'test-input' },
             },
             {
                 name: 'warrantyInfo',
@@ -616,5 +657,6 @@ const comprehensiveTestPaymentHandler = new PaymentMethodHandler({
 
         return config;
     },
+    dashboard: './dashboard/index.tsx',
 })
 export class FieldTestPlugin {}

+ 16 - 30
packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx

@@ -1,7 +1,5 @@
 import {
-    CustomFormComponentInputProps,
-    DataDisplayComponentProps,
-    DataInputComponentProps,
+    DashboardFormComponent,
     FormControl,
     MultiRelationInput,
     RelationSelectorConfig,
@@ -16,17 +14,17 @@ import {
 } from '@vendure/dashboard';
 import { graphql } from '../../../graphql/graphql';
 
-export function TextareaCustomField({ field }: CustomFormComponentInputProps) {
-    return <Textarea {...field} rows={4} />;
-}
+export const TextareaCustomField: DashboardFormComponent = props => {
+    return <Textarea {...props} rows={4} />;
+};
 
-export function ResponseDisplay({ value }: DataDisplayComponentProps) {
+export const ResponseDisplay: DashboardFormComponent = ({ value }) => {
     return <div className="font-mono">{value}</div>;
-}
+};
 
-export function BodyInputComponent(props: DataInputComponentProps) {
+export const BodyInputComponent: DashboardFormComponent = props => {
     return <Textarea {...props} rows={4} />;
-}
+};
 
 const reviewFragment = graphql(`
     fragment Review on ProductReview {
@@ -49,39 +47,27 @@ const reviewListQuery = graphql(
     [reviewFragment],
 );
 
-export function ReviewSingleSelect(props: CustomFormComponentInputProps) {
+export const ReviewSingleSelect: DashboardFormComponent = props => {
     const config: RelationSelectorConfig<ResultOf<typeof reviewFragment>> = {
         listQuery: reviewListQuery,
         labelKey: 'summary',
         idKey: 'id',
     };
 
-    return (
-        <SingleRelationInput
-            value={props.field.value}
-            onChange={props.field.onChange}
-            config={config}
-        ></SingleRelationInput>
-    );
-}
+    return <SingleRelationInput {...props} config={config}></SingleRelationInput>;
+};
 
-export function ReviewMultiSelect(props: CustomFormComponentInputProps) {
+export const ReviewMultiSelect: DashboardFormComponent = props => {
     const config: RelationSelectorConfig<ResultOf<typeof reviewFragment>> = {
         listQuery: reviewListQuery,
         labelKey: 'summary',
         idKey: 'id',
     };
 
-    return (
-        <MultiRelationInput
-            value={props.field.value}
-            onChange={props.field.onChange}
-            config={config}
-        ></MultiRelationInput>
-    );
-}
+    return <MultiRelationInput config={config} {...props}></MultiRelationInput>;
+};
 
-export function ReviewStateSelect(props: DataInputComponentProps) {
+export const ReviewStateSelect: DashboardFormComponent = props => {
     return (
         <Select value={props.value} onValueChange={props.onChange} key={props.value}>
             <FormControl>
@@ -96,4 +82,4 @@ export function ReviewStateSelect(props: DataInputComponentProps) {
             </SelectContent>
         </Select>
     );
-}
+};

+ 0 - 2
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -148,8 +148,6 @@ defineDashboardExtension({
                     field: 'state',
                     component: ReviewStateSelect,
                 },
-            ],
-            displays: [
                 {
                     blockId: 'main-form',
                     field: 'response',

Some files were not shown because too many files changed in this diff