Explorar o código

feat(dashboard): Enhance detail forms extension API with input and display components (#3626)

David Höck hai 6 meses
pai
achega
e6def008ef
Modificáronse 29 ficheiros con 2335 adicións e 396 borrados
  1. 608 0
      docs/docs/guides/extending-the-dashboard/custom-form-components/display-components.md
  2. 291 82
      docs/docs/guides/extending-the-dashboard/custom-form-components/index.md
  3. 482 0
      docs/docs/guides/extending-the-dashboard/custom-form-components/input-components.md
  4. 9 1
      docs/sidebars.js
  5. 31 47
      packages/dashboard/src/lib/framework/component-registry/component-registry.tsx
  6. 29 95
      packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
  7. 69 0
      packages/dashboard/src/lib/framework/extension-api/display-component-extensions.tsx
  8. 18 160
      packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
  9. 69 0
      packages/dashboard/src/lib/framework/extension-api/input-component-extensions.tsx
  10. 10 0
      packages/dashboard/src/lib/framework/extension-api/logic/alerts.ts
  11. 60 0
      packages/dashboard/src/lib/framework/extension-api/logic/data-table.ts
  12. 48 0
      packages/dashboard/src/lib/framework/extension-api/logic/detail-forms.ts
  13. 13 0
      packages/dashboard/src/lib/framework/extension-api/logic/form-components.ts
  14. 8 0
      packages/dashboard/src/lib/framework/extension-api/logic/index.ts
  15. 22 0
      packages/dashboard/src/lib/framework/extension-api/logic/layout.ts
  16. 37 0
      packages/dashboard/src/lib/framework/extension-api/logic/navigation.ts
  17. 10 0
      packages/dashboard/src/lib/framework/extension-api/logic/widgets.ts
  18. 54 0
      packages/dashboard/src/lib/framework/extension-api/types/alerts.ts
  19. 64 0
      packages/dashboard/src/lib/framework/extension-api/types/data-table.ts
  20. 81 0
      packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts
  21. 32 0
      packages/dashboard/src/lib/framework/extension-api/types/form-components.ts
  22. 8 0
      packages/dashboard/src/lib/framework/extension-api/types/index.ts
  23. 78 0
      packages/dashboard/src/lib/framework/extension-api/types/layout.ts
  24. 19 0
      packages/dashboard/src/lib/framework/extension-api/types/navigation.ts
  25. 94 0
      packages/dashboard/src/lib/framework/extension-api/types/widgets.ts
  26. 48 3
      packages/dashboard/src/lib/framework/page/detail-page.tsx
  27. 3 0
      packages/dashboard/src/lib/framework/registry/registry-types.ts
  28. 14 1
      packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx
  29. 26 7
      packages/dev-server/test-plugins/reviews/dashboard/index.tsx

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

@@ -0,0 +1,608 @@
+---
+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

+ 291 - 82
docs/docs/guides/extending-the-dashboard/custom-form-components/index.md

@@ -1,16 +1,88 @@
 ---
-title: 'Custom Form Components'
+title: 'Custom Form Elements'
 ---
 
-The dashboard allows you to create custom form components for custom fields, giving you complete control over how custom fields are rendered and how users interact with them. This is particularly useful for complex data types or specialized input requirements.
+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
 
 :::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.
 :::
 
-## Basic Custom Form Component
+## Registration Approach
+
+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
+
+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.
 
-Custom form components are React components that receive React Hook Form render props and use the dashboard's Shadcn UI design system for consistent styling.
+### Basic Custom Field Component
 
 ```tsx title="src/plugins/my-plugin/dashboard/components/color-picker.tsx"
 import { CustomFormComponentInputProps, Input, Button, Card, CardContent } from '@vendure/dashboard';
@@ -66,84 +138,183 @@ export function ColorPickerComponent({ field, fieldState }: CustomFormComponentI
 }
 ```
 
-## Registering Custom Form Components
+## Input Components
 
-Custom form components are registered in your dashboard extension:
+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.
 
-```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';
+### Basic Input Component
 
-export default defineDashboardExtension({
-    customFormComponents: [
-        {
-            id: 'color-picker',
-            component: ColorPickerComponent,
-        },
-        {
-            id: 'rich-text-editor',
-            component: RichTextEditorComponent,
-        },
-        {
-            id: 'tags-input',
-            component: TagsInputComponent,
-        },
-    ],
-    // ... other extension properties
-});
+```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';
+
+interface PriceInputProps {
+    value: number;
+    onChange: (value: number) => void;
+    disabled?: boolean;
+}
+
+export function PriceInputComponent({ value, onChange, disabled }: PriceInputProps) {
+    const [currency, setCurrency] = useState('USD');
+
+    const currencies = [
+        { code: 'USD', symbol: '$', icon: DollarSign },
+        { code: 'EUR', symbol: '€', icon: Euro },
+        { code: 'GBP', symbol: '£', icon: Pound },
+    ];
+
+    const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];
+    const Icon = selectedCurrency.icon;
+
+    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>
+    );
+}
 ```
 
-## Using Custom Form Components in Custom Fields
+### Targeting Input Components
 
-Once registered, you can reference your custom form components in your custom field definitions:
+Input components are targeted using three properties:
 
-```ts title="src/plugins/my-plugin/my-plugin.ts"
-import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+- **pageId**: The ID of the page (e.g., 'product-detail', 'customer-detail')
+- **blockId**: The ID of the form block (e.g., 'product-form', 'customer-info')
+- **field**: The name of the field to replace (e.g., 'price', 'email')
 
-@VendurePlugin({
-    imports: [PluginCommonModule],
-    configuration: config => {
-        config.customFields.Product.push(
-            {
-                name: 'brandColor',
-                type: 'string',
-                label: [{ languageCode: LanguageCode.en, value: 'Brand Color' }],
-                description: [
-                    { languageCode: LanguageCode.en, value: 'Primary brand color for this product' },
-                ],
-                ui: {
-                    component: 'color-picker', // References our registered component
-                },
-            },
-            {
-                name: 'description',
-                type: 'text',
-                label: [{ languageCode: LanguageCode.en, value: 'Rich Description' }],
-                ui: {
-                    component: 'rich-text-editor',
-                },
-            },
-            {
-                name: 'tags',
-                type: 'string',
-                label: [{ languageCode: LanguageCode.en, value: 'Product Tags' }],
-                ui: {
-                    component: 'tags-input',
-                },
-            },
-        );
-        return config;
+```tsx
+inputs: [
+    {
+        pageId: 'product-detail',
+        blockId: 'product-form',
+        field: 'price',
+        component: PriceInputComponent,
+    },
+    {
+        pageId: 'customer-detail',
+        blockId: 'customer-info',
+        field: 'email',
+        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,
     },
-    dashboard: './dashboard/index.tsx',
-})
-export class MyPlugin {}
+];
 ```
 
-## Component Props Interface
+## Component Props Interfaces
 
-Your custom form components receive React Hook Form render props through the following interface:
+### Custom Field Component Props
+
+Custom field components receive React Hook Form render props:
 
 ```tsx
 interface CustomFormComponentInputProps {
@@ -178,6 +349,35 @@ interface CustomFormComponentInputProps {
 }
 ```
 
+### 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
@@ -350,16 +550,6 @@ export function ValidatedInputComponent({ field, fieldState }: CustomFormCompone
 }
 ```
 
-:::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
-3. **Display validation errors** from `fieldState.error` when they exist
-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 checking the field state or form state
-   :::
-
 ## Integration with Dashboard Design System
 
 Leverage the dashboard's existing Shadcn UI components for consistent design:
@@ -409,12 +599,31 @@ export function EnhancedSelectComponent({ field, fieldState }: CustomFormCompone
 }
 ```
 
+:::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
-Custom form components must be registered before they can be used in custom field definitions. The `id` you use when registering the component is what you reference in the custom field's `ui.component` property.
+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.
 :::
 
 :::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.
 :::
 
-Custom form components give you complete flexibility in how custom fields are presented and edited in the dashboard, while maintaining seamless integration with React Hook Form and the dashboard's design system.
+The unified custom form elements system gives you complete flexibility in how data is presented and edited in the dashboard, while maintaining seamless integration with React Hook Form and the dashboard's design system.
+
+## Further Reading
+
+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

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

@@ -0,0 +1,482 @@
+---
+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

+ 9 - 1
docs/sidebars.js

@@ -143,7 +143,15 @@ const sidebars = {
                 'guides/extending-the-dashboard/navigation/index',
                 'guides/extending-the-dashboard/page-blocks/index',
                 'guides/extending-the-dashboard/action-bar-items/index',
-                'guides/extending-the-dashboard/custom-form-components/index',
+                {
+                    type: 'category',
+                    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/tech-stack/index',
             ],
         },

+ 31 - 47
packages/dashboard/src/lib/framework/component-registry/component-registry.tsx

@@ -1,70 +1,54 @@
-import { BooleanDisplayBadge, BooleanDisplayCheckbox } from '@/components/data-display/boolean.js';
-import { DateTime } from '@/components/data-display/date-time.js';
-import { Money } from '@/components/data-display/money.js';
-import { DateTimeInput } from '@/components/data-input/datetime-input.js';
-import { FacetValueInput } from '@/components/data-input/facet-value-input.js';
-import { MoneyInput } from '@/components/data-input/money-input.js';
-import { VendureImage } from '@/components/shared/vendure-image.js';
-import { Checkbox } from '@/components/ui/checkbox.js';
-import { Input } from '@/components/ui/input.js';
 import * as React from 'react';
+import { addDisplayComponent, getDisplayComponent } from '../extension-api/display-component-extensions.js';
+import { addInputComponent, getInputComponent } from '../extension-api/input-component-extensions.js';
 
 export interface ComponentRegistryEntry<Props extends Record<string, any>> {
     component: React.ComponentType<Props>;
 }
 
 // Basic component types
-export type DataDisplayComponent = React.ComponentType<{ value: any; [key: string]: any }>;
-export type DataInputComponent = React.ComponentType<{ value: any; onChange: (value: any) => void; [key: string]: any }>;
 
-// Simple component registry
-interface ComponentRegistry {
-    dataDisplay: Record<string, DataDisplayComponent>;
-    dataInput: Record<string, DataInputComponent>;
+export interface DataDisplayComponentProps {
+    value: any;
+    [key: string]: any;
 }
 
-export const COMPONENT_REGISTRY: ComponentRegistry = {
-    dataDisplay: {
-        'vendure:booleanCheckbox': BooleanDisplayCheckbox,
-        'vendure:booleanBadge': BooleanDisplayBadge,
-        'vendure:dateTime': DateTime,
-        'vendure:asset': ({value}) => <VendureImage asset={value} preset="tiny" />,
-        'vendure:money': Money,
-    },
-    dataInput: {
-        'vendure:moneyInput': MoneyInput,
-        'vendure:textInput': (props) => <Input {...props} onChange={e => props.onChange(e.target.value)} />,
-        'vendure:numberInput': (props) => <Input {...props} onChange={e => props.onChange(e.target.value)} type="number" />,
-        'vendure:dateTimeInput': DateTimeInput,
-        'vendure:checkboxInput': (props) => <Checkbox {...props} checked={props.value === 'true' || props.value === true}  onCheckedChange={value => props.onChange(value)} />,
-        'vendure:facetValueInput': FacetValueInput,
-    }
-};
+export interface DataInputComponentProps {
+    value: any;
+    onChange: (value: any) => void;
+    [key: string]: any;
+}
+
+export type DataDisplayComponent = React.ComponentType<DataDisplayComponentProps>;
+export type DataInputComponent = React.ComponentType<DataInputComponentProps>;
 
-// Simplified implementation - replace with actual implementation
+// Component registry hook that uses the global registry
 export function useComponentRegistry() {
     return {
         getDisplayComponent: (id: string): DataDisplayComponent | undefined => {
-            // This is a placeholder implementation
-            return COMPONENT_REGISTRY.dataDisplay[id];
+            return getDisplayComponent(id);
         },
         getInputComponent: (id: string): DataInputComponent | undefined => {
-            // This is a placeholder implementation
-            return COMPONENT_REGISTRY.dataInput[id];
+            return getInputComponent(id);
         },
     };
 }
 
-export function registerInputComponent(id: string,  component: DataInputComponent) {
-    if (COMPONENT_REGISTRY.dataInput[id]) {
-        throw new Error(`Input component with id ${id} already registered`);
-    }
-    COMPONENT_REGISTRY.dataInput[id] = component;
+// 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(id: string, component: DataDisplayComponent) {
-    if (COMPONENT_REGISTRY.dataDisplay[id]) {
-        throw new Error(`Display component with id ${id} already registered`);
-    }
-    COMPONENT_REGISTRY.dataDisplay[id] = component;
+export function registerDisplayComponent(
+    pageId: string,
+    blockId: string,
+    field: string,
+    component: DataDisplayComponent,
+) {
+    addDisplayComponent({ pageId, blockId, field, component });
 }

+ 29 - 95
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -1,20 +1,15 @@
-import { addBulkAction, addListQueryDocument } from '@/framework/data-table/data-table-extensions.js';
-import { parse } from 'graphql';
-
-import { registerDashboardWidget } from '../dashboard-widget/widget-extensions.js';
-import {
-    addCustomFormComponent,
-    addDetailQueryDocument,
-} from '../form-engine/custom-form-component-extensions.js';
-import {
-    registerDashboardActionBarItem,
-    registerDashboardPageBlock,
-} from '../layout-engine/layout-extensions.js';
-import { addNavMenuItem, addNavMenuSection, NavMenuItem } from '../nav-menu/nav-menu-extensions.js';
-import { registerRoute } from '../page/page-api.js';
 import { globalRegistry } from '../registry/global-registry.js';
 
 import { DashboardExtension } from './extension-api-types.js';
+import {
+    registerAlertExtensions,
+    registerDataTableExtensions,
+    registerDetailFormExtensions,
+    registerFormComponentExtensions,
+    registerLayoutExtensions,
+    registerNavigationExtensions,
+    registerWidgetExtensions,
+} from './logic/index.js';
 
 globalRegistry.register('extensionSourceChangeCallbacks', new Set<() => void>());
 globalRegistry.register('registerDashboardExtensionCallbacks', new Set<() => void>());
@@ -41,89 +36,28 @@ export function executeDashboardExtensionCallbacks() {
  */
 export function defineDashboardExtension(extension: DashboardExtension) {
     globalRegistry.get('registerDashboardExtensionCallbacks').add(() => {
-        if (extension.navSections) {
-            for (const section of extension.navSections) {
-                addNavMenuSection({
-                    ...section,
-                    placement: 'top',
-                    order: section.order ?? 999,
-                    items: [],
-                });
-            }
-        }
-        if (extension.routes) {
-            for (const route of extension.routes) {
-                if (route.navMenuItem) {
-                    // Add the nav menu item
-                    const item: NavMenuItem = {
-                        url: route.navMenuItem.url ?? route.path,
-                        id: route.navMenuItem.id ?? route.path,
-                        title: route.navMenuItem.title ?? route.path,
-                    };
-                    addNavMenuItem(item, route.navMenuItem.sectionId);
-                }
-                if (route.path) {
-                    // Configure a list page
-                    registerRoute(route);
-                }
-            }
-        }
-        if (extension.actionBarItems) {
-            for (const item of extension.actionBarItems) {
-                registerDashboardActionBarItem(item);
-            }
-        }
-        if (extension.pageBlocks) {
-            for (const block of extension.pageBlocks) {
-                registerDashboardPageBlock(block);
-            }
-        }
-        if (extension.widgets) {
-            for (const widget of extension.widgets) {
-                registerDashboardWidget(widget);
-            }
-        }
-        if (extension.customFormComponents) {
-            for (const component of extension.customFormComponents) {
-                addCustomFormComponent(component);
-            }
-        }
-        if (extension.dataTables) {
-            for (const dataTable of extension.dataTables) {
-                if (dataTable.bulkActions?.length) {
-                    for (const action of dataTable.bulkActions) {
-                        addBulkAction(dataTable.pageId, dataTable.blockId, action);
-                    }
-                }
-                if (dataTable.extendListDocument) {
-                    const document =
-                        typeof dataTable.extendListDocument === 'function'
-                            ? dataTable.extendListDocument()
-                            : dataTable.extendListDocument;
+        // Register navigation extensions (nav sections and routes)
+        registerNavigationExtensions(extension.navSections, extension.routes);
 
-                    addListQueryDocument(
-                        dataTable.pageId,
-                        dataTable.blockId,
-                        typeof document === 'string' ? parse(document) : document,
-                    );
-                }
-            }
-        }
-        if (extension.detailForms) {
-            for (const detailForm of extension.detailForms) {
-                if (detailForm.extendDetailDocument) {
-                    const document =
-                        typeof detailForm.extendDetailDocument === 'function'
-                            ? detailForm.extendDetailDocument()
-                            : detailForm.extendDetailDocument;
+        // Register layout extensions (action bar items and page blocks)
+        registerLayoutExtensions(extension.actionBarItems, extension.pageBlocks);
 
-                    addDetailQueryDocument(
-                        detailForm.pageId,
-                        typeof document === 'string' ? parse(document) : document,
-                    );
-                }
-            }
-        }
+        // Register widget extensions
+        registerWidgetExtensions(extension.widgets);
+
+        // Register form component extensions (custom form components, input components, and display components)
+        registerFormComponentExtensions(extension.customFormComponents);
+
+        // Register data table extensions
+        registerDataTableExtensions(extension.dataTables);
+
+        // Register detail form extensions
+        registerDetailFormExtensions(extension.detailForms);
+
+        // Register alert extensions
+        registerAlertExtensions(extension.alerts);
+
+        // Execute extension source change callbacks
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {
             for (const callback of callbacks) {

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

@@ -0,0 +1,69 @@
+import { BooleanDisplayBadge, BooleanDisplayCheckbox } from '@/components/data-display/boolean.js';
+import { DateTime } from '@/components/data-display/date-time.js';
+import { Money } from '@/components/data-display/money.js';
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { DataDisplayComponent } from '../component-registry/component-registry.js';
+import { globalRegistry } from '../registry/global-registry.js';
+
+globalRegistry.register('displayComponents', new Map<string, DataDisplayComponent>());
+
+// Create component function for asset display
+const AssetDisplay: DataDisplayComponent = ({ value }) => <VendureImage asset={value} preset="tiny" />;
+
+// Register built-in display components
+const displayComponents = globalRegistry.get('displayComponents');
+displayComponents.set('vendure:booleanCheckbox', BooleanDisplayCheckbox);
+displayComponents.set('vendure:booleanBadge', BooleanDisplayBadge);
+displayComponents.set('vendure:dateTime', DateTime);
+displayComponents.set('vendure:asset', AssetDisplay);
+displayComponents.set('vendure:money', Money);
+
+export function getDisplayComponent(id: string): DataDisplayComponent | undefined {
+    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.
+ * Follows the existing pattern: pageId_blockId_fieldName
+ */
+export function generateDisplayComponentKey(pageId: string, blockId: string, field: string): string {
+    return `${pageId}_${blockId}_${field}`;
+}
+
+export function addDisplayComponent({
+    pageId,
+    blockId,
+    field,
+    component,
+}: {
+    pageId: string;
+    blockId: string;
+    field: string;
+    component: React.ComponentType<{ value: any; [key: string]: any }>;
+}) {
+    const displayComponents = globalRegistry.get('displayComponents');
+
+    // Generate the key using the helper function
+    const key = generateDisplayComponentKey(pageId, blockId, field);
+
+    if (displayComponents.has(key)) {
+        // eslint-disable-next-line no-console
+        console.warn(`Display component with key "${key}" is already registered and will be overwritten.`);
+    }
+    displayComponents.set(key, component);
+}

+ 18 - 160
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -1,161 +1,18 @@
-import { PageContextValue } from '@/framework/layout-engine/page-provider.js';
-import { AnyRoute, RouteOptions } from '@tanstack/react-router';
-import { DocumentNode } from 'graphql';
-import { LucideIcon } from 'lucide-react';
-import type React from 'react';
+// Import all domain-specific types
+export * from './types/index.js';
 
-import { DashboardAlertDefinition } from '../alert/types.js';
-import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
-import { BulkAction } from '../data-table/data-table-types.js';
-import { CustomFormComponentInputProps } from '../form-engine/custom-form-component.js';
-import { NavMenuItem } from '../nav-menu/nav-menu-extensions.js';
-
-/**
- * @description
- * Allows you to define custom form components for custom fields in the dashboard.
- *
- * @docsCategory extensions
- * @since 3.4.0
- */
-export interface DashboardCustomFormComponent {
-    id: string;
-    component: React.FunctionComponent<CustomFormComponentInputProps>;
-}
-
-export interface DashboardRouteDefinition {
-    component: (route: AnyRoute) => React.ReactNode;
-    path: string;
-    navMenuItem?: Partial<NavMenuItem> & { sectionId: string };
-    loader?: RouteOptions['loader'];
-}
-
-export interface ActionBarButtonState {
-    disabled: boolean;
-    visible: boolean;
-}
-
-export interface DashboardNavSectionDefinition {
-    id: string;
-    title: string;
-    icon?: LucideIcon;
-    order?: number;
-}
-
-/**
- * @description
- * **Status: Developer Preview**
- *
- * Allows you to define custom action bar items for any page in the dashboard.
- *
- * @docsCategory extensions
- * @since 3.3.0
- */
-export interface DashboardActionBarItem {
-    /**
-     * @description
-     * The ID of the page where the action bar item should be displayed.
-     */
-    pageId: string;
-    /**
-     * @description
-     * A React component that will be rendered in the action bar.
-     */
-    component: React.FunctionComponent<{ context: PageContextValue }>;
-    /**
-     * @description
-     * Any permissions that are required to display this action bar item.
-     */
-    requiresPermission?: string | string[];
-}
-
-export interface DashboardActionBarDropdownMenuItem {
-    locationId: string;
-    component: React.FunctionComponent<{ context: PageContextValue }>;
-    requiresPermission?: string | string[];
-}
-
-export type PageBlockPosition = { blockId: string; order: 'before' | 'after' | 'replace' };
-
-/**
- * @description
- * **Status: Developer Preview**
- *
- * The location of a page block in the dashboard. The location can be found by turning on
- * "developer mode" in the dashboard user menu (bottom left corner) and then
- * clicking the `< />` icon when hovering over a page block.
- *
- * @docsCategory extensions
- * @since 3.3.0
- */
-export type PageBlockLocation = {
-    pageId: string;
-    position: PageBlockPosition;
-    column: 'main' | 'side';
-};
-
-/**
- * @description
- * **Status: Developer Preview**
- *
- * This allows you to insert a custom component into a specific location
- * on any page in the dashboard.
- *
- * @docsCategory extensions
- * @since 3.3.0
- */
-export interface DashboardPageBlockDefinition {
-    id: string;
-    title?: React.ReactNode;
-    location: PageBlockLocation;
-    component: React.FunctionComponent<{ context: PageContextValue }>;
-    requiresPermission?: string | string[];
-}
-
-/**
- * @description
- * **Status: Developer Preview**
- *
- * This allows you to customize aspects of existing data tables in the dashboard.
- *
- * @docsCategory extensions
- * @since 3.4.0
- */
-export interface DashboardDataTableExtensionDefinition {
-    /**
-     * @description
-     * The ID of the page where the data table is located, e.g. `'product-list'`, `'order-list'`.
-     */
-    pageId: string;
-    /**
-     * @description
-     * The ID of the data table block. Defaults to `'list-table'`, which is the default blockId
-     * for the standard list pages. However, some other pages may use a different blockId,
-     * such as `'product-variants-table'` on the `'product-detail'` page.
-     */
-    blockId?: string;
-    /**
-     * @description
-     * An array of additional bulk actions that will be available on the data table.
-     */
-    bulkActions?: BulkAction[];
-    /**
-     * @description
-     * Allows you to extend the list document for the data table.
-     */
-    extendListDocument?: string | DocumentNode | (() => DocumentNode | string);
-}
-
-export interface DashboardDetailFormExtensionDefinition {
-    /**
-     * @description
-     * The ID of the page where the detail form is located, e.g. `'product-detail'`, `'order-detail'`.
-     */
-    pageId: string;
-    /**
-     * @description
-     */
-    extendDetailDocument?: string | DocumentNode | (() => DocumentNode | string);
-}
+// Import types for the main interface
+import {
+    DashboardActionBarItem,
+    DashboardAlertDefinition,
+    DashboardCustomFormComponents,
+    DashboardDataTableExtensionDefinition,
+    DashboardDetailFormExtensionDefinition,
+    DashboardNavSectionDefinition,
+    DashboardPageBlockDefinition,
+    DashboardRouteDefinition,
+    DashboardWidgetDefinition,
+} from './types/index.js';
 
 /**
  * @description
@@ -189,7 +46,7 @@ export interface DashboardExtension {
     actionBarItems?: DashboardActionBarItem[];
     /**
      * @description
-     * Not yet implemented
+     * Allows you to define custom alerts that can be displayed in the dashboard.
      */
     alerts?: DashboardAlertDefinition[];
     /**
@@ -200,9 +57,10 @@ export interface DashboardExtension {
     widgets?: DashboardWidgetDefinition[];
     /**
      * @description
-     * Allows you to define custom form components for custom fields in the dashboard.
+     * Unified registration for custom form components including custom field components,
+     * input components, and display components.
      */
-    customFormComponents?: DashboardCustomFormComponent[];
+    customFormComponents?: DashboardCustomFormComponents;
     /**
      * @description
      * Allows you to customize aspects of existing data tables in the dashboard.

+ 69 - 0
packages/dashboard/src/lib/framework/extension-api/input-component-extensions.tsx

@@ -0,0 +1,69 @@
+import { DateTimeInput } from '@/components/data-input/datetime-input.js';
+import { FacetValueInput } from '@/components/data-input/facet-value-input.js';
+import { MoneyInput } from '@/components/data-input/money-input.js';
+import { Checkbox } from '@/components/ui/checkbox.js';
+import { Input } from '@/components/ui/input.js';
+import { DataInputComponent } from '../component-registry/component-registry.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)}
+    />
+);
+
+// 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);
+
+export function getInputComponent(id: string): DataInputComponent | undefined {
+    return globalRegistry.get('inputComponents').get(id);
+}
+
+/**
+ * @description
+ * Generates a component key based on the targeting properties.
+ * Follows the existing pattern: pageId_blockId_fieldName
+ */
+export function generateInputComponentKey(pageId: string, blockId: string, field: string): string {
+    return `${pageId}_${blockId}_${field}`;
+}
+
+export function addInputComponent({
+    pageId,
+    blockId,
+    field,
+    component,
+}: {
+    pageId: string;
+    blockId: string;
+    field: string;
+    component: React.ComponentType<{ value: any; onChange: (value: any) => void; [key: string]: any }>;
+}) {
+    const inputComponents = globalRegistry.get('inputComponents');
+
+    // Generate the key using the helper function
+    const key = generateInputComponentKey(pageId, blockId, field);
+
+    if (inputComponents.has(key)) {
+        // eslint-disable-next-line no-console
+        console.warn(`Input component with key "${key}" is already registered and will be overwritten.`);
+    }
+    inputComponents.set(key, component);
+}

+ 10 - 0
packages/dashboard/src/lib/framework/extension-api/logic/alerts.ts

@@ -0,0 +1,10 @@
+import { globalRegistry } from '../../registry/global-registry.js';
+import { DashboardAlertDefinition } from '../types/alerts.js';
+
+export function registerAlertExtensions(alerts?: DashboardAlertDefinition[]) {
+    if (alerts) {
+        for (const alert of alerts) {
+            globalRegistry.get('dashboardAlertRegistry').set(alert.id, alert);
+        }
+    }
+}

+ 60 - 0
packages/dashboard/src/lib/framework/extension-api/logic/data-table.ts

@@ -0,0 +1,60 @@
+import { parse } from 'graphql';
+
+import { addBulkAction, addListQueryDocument } from '../../data-table/data-table-extensions.js';
+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) {
+            if (dataTable.bulkActions?.length) {
+                for (const action of dataTable.bulkActions) {
+                    addBulkAction(dataTable.pageId, dataTable.blockId, action);
+                }
+            }
+            if (dataTable.extendListDocument) {
+                const document =
+                    typeof dataTable.extendListDocument === 'function'
+                        ? dataTable.extendListDocument()
+                        : dataTable.extendListDocument;
+
+                addListQueryDocument(
+                    dataTable.pageId,
+                    dataTable.blockId,
+                    typeof document === 'string' ? parse(document) : document,
+                );
+            }
+            if (dataTable.displayComponents?.length) {
+                for (const displayComponent of dataTable.displayComponents) {
+                    addDataTableDisplayComponent(
+                        dataTable.pageId,
+                        displayComponent.column,
+                        displayComponent.component,
+                    );
+                }
+            }
+        }
+    }
+}

+ 48 - 0
packages/dashboard/src/lib/framework/extension-api/logic/detail-forms.ts

@@ -0,0 +1,48 @@
+import { addDetailQueryDocument } from '@/framework/form-engine/custom-form-component-extensions.js';
+import { parse } from 'graphql';
+
+import { addDisplayComponent } from '../display-component-extensions.js';
+import { addInputComponent } from '../input-component-extensions.js';
+import { DashboardDetailFormExtensionDefinition } from '../types/detail-forms.js';
+
+export function registerDetailFormExtensions(detailForms?: DashboardDetailFormExtensionDefinition[]) {
+    if (detailForms) {
+        for (const detailForm of detailForms) {
+            if (detailForm.extendDetailDocument) {
+                const document =
+                    typeof detailForm.extendDetailDocument === 'function'
+                        ? detailForm.extendDetailDocument()
+                        : detailForm.extendDetailDocument;
+
+                addDetailQueryDocument(
+                    detailForm.pageId,
+                    typeof document === 'string' ? parse(document) : document,
+                );
+            }
+
+            // Register input components for this detail form
+            if (detailForm.inputs) {
+                for (const inputComponent of detailForm.inputs) {
+                    addInputComponent({
+                        pageId: detailForm.pageId,
+                        blockId: inputComponent.blockId,
+                        field: inputComponent.field,
+                        component: inputComponent.component,
+                    });
+                }
+            }
+
+            // Register display components for this detail form
+            if (detailForm.displays) {
+                for (const displayComponent of detailForm.displays) {
+                    addDisplayComponent({
+                        pageId: detailForm.pageId,
+                        blockId: displayComponent.blockId,
+                        field: displayComponent.field,
+                        component: displayComponent.component,
+                    });
+                }
+            }
+        }
+    }
+}

+ 13 - 0
packages/dashboard/src/lib/framework/extension-api/logic/form-components.ts

@@ -0,0 +1,13 @@
+import { addCustomFormComponent } from '../../form-engine/custom-form-component-extensions.js';
+import { DashboardCustomFormComponents } from '../types/form-components.js';
+
+export function registerFormComponentExtensions(customFormComponents?: DashboardCustomFormComponents) {
+    if (customFormComponents) {
+        // Handle custom field components
+        if (customFormComponents.customFields) {
+            for (const component of customFormComponents.customFields) {
+                addCustomFormComponent(component);
+            }
+        }
+    }
+}

+ 8 - 0
packages/dashboard/src/lib/framework/extension-api/logic/index.ts

@@ -0,0 +1,8 @@
+// Re-export all domain-specific logic functions
+export * from './alerts.js';
+export * from './data-table.js';
+export * from './detail-forms.js';
+export * from './form-components.js';
+export * from './layout.js';
+export * from './navigation.js';
+export * from './widgets.js';

+ 22 - 0
packages/dashboard/src/lib/framework/extension-api/logic/layout.ts

@@ -0,0 +1,22 @@
+import {
+    registerDashboardActionBarItem,
+    registerDashboardPageBlock,
+} from '../../layout-engine/layout-extensions.js';
+import { DashboardActionBarItem, DashboardPageBlockDefinition } from '../types/layout.js';
+
+export function registerLayoutExtensions(
+    actionBarItems?: DashboardActionBarItem[],
+    pageBlocks?: DashboardPageBlockDefinition[],
+) {
+    if (actionBarItems) {
+        for (const item of actionBarItems) {
+            registerDashboardActionBarItem(item);
+        }
+    }
+
+    if (pageBlocks) {
+        for (const block of pageBlocks) {
+            registerDashboardPageBlock(block);
+        }
+    }
+}

+ 37 - 0
packages/dashboard/src/lib/framework/extension-api/logic/navigation.ts

@@ -0,0 +1,37 @@
+import { addNavMenuItem, addNavMenuSection, NavMenuItem } from '../../nav-menu/nav-menu-extensions.js';
+import { registerRoute } from '../../page/page-api.js';
+import { DashboardNavSectionDefinition, DashboardRouteDefinition } from '../types/navigation.js';
+
+export function registerNavigationExtensions(
+    navSections?: DashboardNavSectionDefinition[],
+    routes?: DashboardRouteDefinition[],
+) {
+    if (navSections) {
+        for (const section of navSections) {
+            addNavMenuSection({
+                ...section,
+                placement: 'top',
+                order: section.order ?? 999,
+                items: [],
+            });
+        }
+    }
+
+    if (routes) {
+        for (const route of routes) {
+            if (route.navMenuItem) {
+                // Add the nav menu item
+                const item: NavMenuItem = {
+                    url: route.navMenuItem.url ?? route.path,
+                    id: route.navMenuItem.id ?? route.path,
+                    title: route.navMenuItem.title ?? route.path,
+                };
+                addNavMenuItem(item, route.navMenuItem.sectionId);
+            }
+            if (route.path) {
+                // Configure a list page
+                registerRoute(route);
+            }
+        }
+    }
+}

+ 10 - 0
packages/dashboard/src/lib/framework/extension-api/logic/widgets.ts

@@ -0,0 +1,10 @@
+import { registerDashboardWidget } from '../../dashboard-widget/widget-extensions.js';
+import { DashboardWidgetDefinition } from '../types/index.js';
+
+export function registerWidgetExtensions(widgets?: DashboardWidgetDefinition[]) {
+    if (widgets) {
+        for (const widget of widgets) {
+            registerDashboardWidget(widget);
+        }
+    }
+}

+ 54 - 0
packages/dashboard/src/lib/framework/extension-api/types/alerts.ts

@@ -0,0 +1,54 @@
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Allows you to define custom alerts that can be displayed in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface DashboardAlertDefinition<TResponse = any> {
+    /**
+     * @description
+     * A unique identifier for the alert.
+     */
+    id: string;
+    /**
+     * @description
+     * The title of the alert. Can be a string or a function that returns a string based on the response data.
+     */
+    title: string | ((data: TResponse) => string);
+    /**
+     * @description
+     * The description of the alert. Can be a string or a function that returns a string based on the response data.
+     */
+    description?: string | ((data: TResponse) => string);
+    /**
+     * @description
+     * The severity level of the alert.
+     */
+    severity: 'info' | 'warning' | 'error';
+    /**
+     * @description
+     * A function that checks the condition and returns the response data.
+     */
+    check: () => Promise<TResponse> | TResponse;
+    /**
+     * @description
+     * The interval in milliseconds to recheck the condition.
+     */
+    recheckInterval?: number;
+    /**
+     * @description
+     * A function that determines whether the alert should be shown based on the response data.
+     */
+    shouldShow?: (data: TResponse) => boolean;
+    /**
+     * @description
+     * Optional actions that can be performed when the alert is shown.
+     */
+    actions?: Array<{
+        label: string;
+        onClick: (data: TResponse) => void;
+    }>;
+}

+ 64 - 0
packages/dashboard/src/lib/framework/extension-api/types/data-table.ts

@@ -0,0 +1,64 @@
+import { DocumentNode } from 'graphql';
+
+import { BulkAction } from '../../data-table/data-table-types.js';
+
+/**
+ * @description
+ * Allows you to define custom display components for specific columns in data tables.
+ * The pageId is already defined in the data table extension, so only the column name is needed.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardDataTableDisplayComponent {
+    /**
+     * @description
+     * The name of the column where this display component should be used.
+     */
+    column: string;
+    /**
+     * @description
+     * The React component that will be rendered as the display.
+     * It should accept `value` and other standard display props.
+     */
+    component: React.ComponentType<{ value: any; [key: string]: any }>;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * This allows you to customize aspects of existing data tables in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardDataTableExtensionDefinition {
+    /**
+     * @description
+     * The ID of the page where the data table is located, e.g. `'product-list'`, `'order-list'`.
+     */
+    pageId: string;
+    /**
+     * @description
+     * The ID of the data table block. Defaults to `'list-table'`, which is the default blockId
+     * for the standard list pages. However, some other pages may use a different blockId,
+     * such as `'product-variants-table'` on the `'product-detail'` page.
+     */
+    blockId?: string;
+    /**
+     * @description
+     * An array of additional bulk actions that will be available on the data table.
+     */
+    bulkActions?: BulkAction[];
+    /**
+     * @description
+     * Allows you to extend the list document for the data table.
+     */
+    extendListDocument?: string | DocumentNode | (() => DocumentNode | string);
+    /**
+     * @description
+     * Custom display components for specific columns in the data table.
+     */
+    displayComponents?: DashboardDataTableDisplayComponent[];
+}

+ 81 - 0
packages/dashboard/src/lib/framework/extension-api/types/detail-forms.ts

@@ -0,0 +1,81 @@
+import {
+    DataDisplayComponent,
+    DataInputComponent,
+} from '@/framework/component-registry/component-registry.js';
+import { DocumentNode } from 'graphql';
+
+/**
+ * @description
+ * 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.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardDetailFormInputComponent {
+    /**
+     * @description
+     * The ID of the block where this input component should be used.
+     */
+    blockId: string;
+    /**
+     * @description
+     * The name of the field where this input component should be used.
+     */
+    field: string;
+    /**
+     * @description
+     * 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
+ * @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;
+}
+
+export interface DashboardDetailFormExtensionDefinition {
+    /**
+     * @description
+     * The ID of the page where the detail form is located, e.g. `'product-detail'`, `'order-detail'`.
+     */
+    pageId: string;
+    /**
+     * @description
+     */
+    extendDetailDocument?: string | DocumentNode | (() => DocumentNode | string);
+    /**
+     * @description
+     * 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[];
+}

+ 32 - 0
packages/dashboard/src/lib/framework/extension-api/types/form-components.ts

@@ -0,0 +1,32 @@
+import type React from 'react';
+
+import { CustomFormComponentInputProps } from '../../form-engine/custom-form-component.js';
+
+/**
+ * @description
+ * Allows you to define custom form components for custom fields in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardCustomFormComponent {
+    id: string;
+    component: React.FunctionComponent<CustomFormComponentInputProps>;
+}
+
+/**
+ * @description
+ * Interface for registering custom field components in the dashboard.
+ * For input and display components, use the co-located approach with detailForms.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardCustomFormComponents {
+    /**
+     * @description
+     * Custom form components for custom fields. These are used when rendering
+     * custom fields in forms.
+     */
+    customFields?: DashboardCustomFormComponent[];
+}

+ 8 - 0
packages/dashboard/src/lib/framework/extension-api/types/index.ts

@@ -0,0 +1,8 @@
+// Re-export all domain-specific types
+export * from './alerts.js';
+export * from './data-table.js';
+export * from './detail-forms.js';
+export * from './form-components.js';
+export * from './layout.js';
+export * from './navigation.js';
+export * from './widgets.js';

+ 78 - 0
packages/dashboard/src/lib/framework/extension-api/types/layout.ts

@@ -0,0 +1,78 @@
+import type React from 'react';
+
+import { PageContextValue } from '../../layout-engine/page-provider.js';
+
+export interface ActionBarButtonState {
+    disabled: boolean;
+    visible: boolean;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Allows you to define custom action bar items for any page in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface DashboardActionBarItem {
+    /**
+     * @description
+     * The ID of the page where the action bar item should be displayed.
+     */
+    pageId: string;
+    /**
+     * @description
+     * A React component that will be rendered in the action bar.
+     */
+    component: React.FunctionComponent<{ context: PageContextValue }>;
+    /**
+     * @description
+     * Any permissions that are required to display this action bar item.
+     */
+    requiresPermission?: string | string[];
+}
+
+export interface DashboardActionBarDropdownMenuItem {
+    locationId: string;
+    component: React.FunctionComponent<{ context: PageContextValue }>;
+    requiresPermission?: string | string[];
+}
+
+export type PageBlockPosition = { blockId: string; order: 'before' | 'after' | 'replace' };
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * The location of a page block in the dashboard. The location can be found by turning on
+ * "developer mode" in the dashboard user menu (bottom left corner) and then
+ * clicking the `< />` icon when hovering over a page block.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export type PageBlockLocation = {
+    pageId: string;
+    position: PageBlockPosition;
+    column: 'main' | 'side';
+};
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * This allows you to insert a custom component into a specific location
+ * on any page in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface DashboardPageBlockDefinition {
+    id: string;
+    title?: React.ReactNode;
+    location: PageBlockLocation;
+    component: React.FunctionComponent<{ context: PageContextValue }>;
+    requiresPermission?: string | string[];
+}

+ 19 - 0
packages/dashboard/src/lib/framework/extension-api/types/navigation.ts

@@ -0,0 +1,19 @@
+import { AnyRoute, RouteOptions } from '@tanstack/react-router';
+import { LucideIcon } from 'lucide-react';
+import type React from 'react';
+
+import { NavMenuItem } from '../../nav-menu/nav-menu-extensions.js';
+
+export interface DashboardRouteDefinition {
+    component: (route: AnyRoute) => React.ReactNode;
+    path: string;
+    navMenuItem?: Partial<NavMenuItem> & { sectionId: string };
+    loader?: RouteOptions['loader'];
+}
+
+export interface DashboardNavSectionDefinition {
+    id: string;
+    title: string;
+    icon?: LucideIcon;
+    order?: number;
+}

+ 94 - 0
packages/dashboard/src/lib/framework/extension-api/types/widgets.ts

@@ -0,0 +1,94 @@
+import type React from 'react';
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Base props interface for dashboard widgets.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface DashboardBaseWidgetProps {
+    widgetId: string;
+    config?: Record<string, unknown>;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Represents an instance of a dashboard widget with its layout and configuration.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export type DashboardWidgetInstance = {
+    /**
+     * @description
+     * A unique identifier for the widget instance.
+     */
+    id: string;
+    /**
+     * @description
+     * The ID of the widget definition this instance is based on.
+     */
+    widgetId: string;
+    /**
+     * @description
+     * The layout configuration for the widget.
+     */
+    layout: {
+        x: number;
+        y: number;
+        w: number;
+        h: number;
+    };
+    /**
+     * @description
+     * Optional configuration data for the widget.
+     */
+    config?: Record<string, unknown>;
+};
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Defines a dashboard widget that can be added to the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export type DashboardWidgetDefinition = {
+    /**
+     * @description
+     * A unique identifier for the widget.
+     */
+    id: string;
+    /**
+     * @description
+     * The display name of the widget.
+     */
+    name: string;
+    /**
+     * @description
+     * The React component that renders the widget.
+     */
+    component: React.ComponentType<DashboardBaseWidgetProps>;
+    /**
+     * @description
+     * The default size and position of the widget.
+     */
+    defaultSize: { w: number; h: number; x?: number; y?: number };
+    /**
+     * @description
+     * The minimum size constraints for the widget.
+     */
+    minSize?: { w: number; h: number };
+    /**
+     * @description
+     * The maximum size constraints for the widget.
+     */
+    maxSize?: { w: number; h: number };
+};

+ 48 - 3
packages/dashboard/src/lib/framework/page/detail-page.tsx

@@ -11,11 +11,15 @@ import { AnyRoute, useNavigate } from '@tanstack/react-router';
 import { ResultOf, VariablesOf } from 'gql.tada';
 import { toast } from 'sonner';
 import {
+    FieldInfo,
     getEntityName,
     getOperationVariablesFields,
 } from '../document-introspection/get-document-structure.js';
 
 import { TranslatableFormFieldWrapper } from '@/components/shared/translatable-form-field.js';
+import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
+import { useComponentRegistry } from '../component-registry/component-registry.js';
+import { generateInputComponentKey } from '../extension-api/input-component-extensions.js';
 import {
     CustomFieldsPageBlock,
     DetailFormGrid,
@@ -85,10 +89,37 @@ export interface DetailPageProps<
     setValuesForUpdate: (entity: ResultOf<T>[EntityField]) => VariablesOf<U>['input'];
 }
 
+export interface DetailPageFieldProps<
+    TFieldValues extends FieldValues = FieldValues,
+    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+> {
+    fieldInfo: FieldInfo;
+    field: ControllerRenderProps<TFieldValues, TName>;
+    blockId: string;
+    pageId: string;
+}
+
 /**
  * Renders form input components based on field type
  */
-function renderFieldInput(fieldInfo: { type: string }, field: any) {
+function FieldInputRenderer<
+    TFieldValues extends FieldValues = FieldValues,
+    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+>({ fieldInfo, field, blockId, pageId }: DetailPageFieldProps<TFieldValues, TName>) {
+    const componentRegistry = useComponentRegistry();
+    const customInputComponentKey = generateInputComponentKey(pageId, blockId, fieldInfo.name);
+
+    const DisplayComponent = componentRegistry.getDisplayComponent(customInputComponentKey);
+    const InputComponent = componentRegistry.getInputComponent(customInputComponentKey);
+
+    if (DisplayComponent) {
+        return <DisplayComponent {...field} />;
+    }
+
+    if (InputComponent) {
+        return <InputComponent {...field} />;
+    }
+
     switch (fieldInfo.type) {
         case 'Int':
         case 'Float':
@@ -196,7 +227,14 @@ export function DetailPage<
                                         control={form.control}
                                         name={fieldInfo.name as never}
                                         label={fieldInfo.name}
-                                        render={({ field }) => renderFieldInput(fieldInfo, field)}
+                                        render={({ field }) => (
+                                            <FieldInputRenderer
+                                                fieldInfo={fieldInfo}
+                                                field={field}
+                                                blockId="main-form"
+                                                pageId={pageId}
+                                            />
+                                        )}
                                     />
                                 );
                             })}
@@ -211,7 +249,14 @@ export function DetailPage<
                                         control={form.control}
                                         name={fieldInfo.name as never}
                                         label={fieldInfo.name}
-                                        render={({ field }) => renderFieldInput(fieldInfo, field)}
+                                        render={({ field }) => (
+                                            <FieldInputRenderer
+                                                fieldInfo={fieldInfo}
+                                                field={field}
+                                                blockId="main-form"
+                                                pageId={pageId}
+                                            />
+                                        )}
                                     />
                                 );
                             })}

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

@@ -2,6 +2,7 @@ import { DocumentNode } from 'graphql';
 import React from 'react';
 
 import { DashboardAlertDefinition } from '../alert/types.js';
+import { DataDisplayComponent, DataInputComponent } from '../component-registry/component-registry.js';
 import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
 import { BulkAction } from '../data-table/data-table-types.js';
 import {
@@ -20,6 +21,8 @@ export interface GlobalRegistryContents {
     dashboardWidgetRegistry: Map<string, DashboardWidgetDefinition>;
     dashboardAlertRegistry: Map<string, DashboardAlertDefinition>;
     customFormComponents: Map<string, React.FunctionComponent<CustomFormComponentInputProps>>;
+    inputComponents: Map<string, DataInputComponent>;
+    displayComponents: Map<string, DataDisplayComponent>;
     bulkActionsRegistry: Map<string, BulkAction[]>;
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;
     detailQueryDocumentRegistry: Map<string, DocumentNode[]>;

+ 14 - 1
packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx

@@ -1,5 +1,18 @@
-import { CustomFormComponentInputProps, Textarea } from '@vendure/dashboard';
+import {
+    CustomFormComponentInputProps,
+    DataDisplayComponentProps,
+    DataInputComponentProps,
+    Textarea,
+} from '@vendure/dashboard';
 
 export function TextareaCustomField({ field }: CustomFormComponentInputProps) {
     return <Textarea {...field} rows={4} />;
 }
+
+export function ResponseDisplay({ value }: DataDisplayComponentProps) {
+    return <div className="font-mono">{value}</div>;
+}
+
+export function BodyInputComponent(props: DataInputComponentProps) {
+    return <Textarea {...props} rows={4} />;
+}

+ 26 - 7
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -2,7 +2,7 @@ import { Button, DataTableBulkActionItem, defineDashboardExtension } from '@vend
 import { InfoIcon } from 'lucide-react';
 import { toast } from 'sonner';
 
-import { TextareaCustomField } from './custom-form-components';
+import { BodyInputComponent, ResponseDisplay, TextareaCustomField } from './custom-form-components';
 import { CustomWidget } from './custom-widget';
 import { reviewDetail } from './review-detail';
 import { reviewList } from './review-list';
@@ -48,12 +48,14 @@ defineDashboardExtension({
             },
         },
     ],
-    customFormComponents: [
-        {
-            id: 'textarea',
-            component: TextareaCustomField,
-        },
-    ],
+    customFormComponents: {
+        customFields: [
+            {
+                id: 'textarea',
+                component: TextareaCustomField,
+            },
+        ],
+    },
     detailForms: [
         {
             pageId: 'product-variant-detail',
@@ -86,6 +88,23 @@ defineDashboardExtension({
                 }
             `,
         },
+        {
+            pageId: 'review-detail',
+            inputs: [
+                {
+                    blockId: 'main-form',
+                    field: 'body',
+                    component: BodyInputComponent,
+                },
+            ],
+            displays: [
+                {
+                    blockId: 'main-form',
+                    field: 'response',
+                    component: ResponseDisplay,
+                },
+            ],
+        },
     ],
     dataTables: [
         {