Browse Source

feat(dashboard): Relation selector components (#3633)

David Höck 6 months ago
parent
commit
d648ac1b61

+ 46 - 0
docs/docs/guides/extending-the-dashboard/custom-form-components/index.md

@@ -621,9 +621,55 @@ Always import UI components from the `@vendure/dashboard` package rather than cr
 
 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.
 
+## Relation Selectors
+
+The dashboard includes powerful relation selector components for selecting related entities with built-in search and pagination:
+
+```tsx title="src/plugins/my-plugin/dashboard/components/product-selector.tsx"
+import {
+    SingleRelationInput,
+    createRelationSelectorConfig,
+    graphql,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+const productConfig = createRelationSelectorConfig({
+    listQuery: graphql(`
+        query GetProductsForSelection($options: ProductListOptions) {
+            products(options: $options) {
+                items {
+                    id
+                    name
+                }
+                totalItems
+            }
+        }
+    `),
+    idKey: 'id',
+    labelKey: 'name',
+    placeholder: 'Search products...',
+    buildSearchFilter: (term: string) => ({
+        name: { contains: term },
+    }),
+});
+
+export function ProductSelectorComponent({ value, onChange }: CustomFormComponentInputProps) {
+    return <SingleRelationInput value={value} onChange={onChange} config={productConfig} />;
+}
+```
+
+Features include:
+
+- **Real-time search** with debounced input
+- **Infinite scroll pagination** loading 25 items by default
+- **Single and multi-select modes** with type safety
+- **Customizable GraphQL queries** and search filters
+- **Built-in UI components** using the dashboard 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
+- **[Relation Selectors](./relation-selectors)** - Build powerful entity selection components with search, pagination, and multi-select capabilities for custom fields and form inputs

+ 687 - 0
docs/docs/guides/extending-the-dashboard/custom-form-components/relation-selectors.md

@@ -0,0 +1,687 @@
+---
+title: 'Relation Selectors'
+---
+
+Relation selector components provide a powerful way to select related entities in your dashboard forms. They support both single and multi-selection modes with built-in search, infinite scroll pagination, and complete TypeScript type safety.
+
+## Features
+
+- **Real-time Search**: Debounced search with customizable filters
+- **Infinite Scroll**: Automatic pagination loading 25 items by default
+- **Single/Multi Select**: Easy toggle between selection modes
+- **Type Safe**: Full TypeScript support with generic types
+- **Customizable**: Pass your own GraphQL queries and field mappings
+- **Accessible**: Built with Radix UI primitives
+
+## Components Overview
+
+The relation selector system consists of three main components:
+
+- **`RelationSelector`**: The abstract base component that handles all core functionality
+- **`SingleRelationInput`**: Convenient wrapper for single entity selection
+- **`MultiRelationInput`**: Convenient wrapper for multiple entity selection
+
+## Basic Usage
+
+### Single Selection
+
+```tsx title="src/plugins/my-plugin/dashboard/components/product-selector.tsx"
+import {
+    SingleRelationInput,
+    createRelationSelectorConfig,
+    graphql,
+    ResultOf,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+// Define your GraphQL query
+const productListQuery = graphql(`
+    query GetProductsForSelection($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                name
+                slug
+                featuredAsset {
+                    id
+                    preview
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+// Create the configuration
+const productConfig = createRelationSelectorConfig({
+    listQuery: productListQuery,
+    idKey: 'id',
+    labelKey: 'name',
+    placeholder: 'Search products...',
+    buildSearchFilter: (term: string) => ({
+        name: { contains: term },
+    }),
+});
+
+export function ProductSelectorComponent({ value, onChange, disabled }: CustomFormComponentInputProps) {
+    return (
+        <SingleRelationInput value={value} onChange={onChange} config={productConfig} disabled={disabled} />
+    );
+}
+```
+
+### Multi Selection
+
+```tsx title="src/plugins/my-plugin/dashboard/components/product-multi-selector.tsx"
+import { MultiRelationInput, CustomFormComponentInputProps } from '@vendure/dashboard';
+
+export function ProductMultiSelectorComponent({ value, onChange, disabled }: CustomFormComponentInputProps) {
+    return (
+        <MultiRelationInput
+            value={value || []}
+            onChange={onChange}
+            config={productConfig} // Same config as above
+            disabled={disabled}
+        />
+    );
+}
+```
+
+## Configuration Options
+
+The `createRelationSelectorConfig` function accepts these options:
+
+```tsx
+interface RelationSelectorConfig<T> {
+    /** The GraphQL query document for fetching items */
+    listQuery: DocumentNode;
+    /** The property key for the entity ID */
+    idKey: keyof T;
+    /** The property key for the display label (used as fallback when label function not provided) */
+    labelKey: keyof T;
+    /** Number of items to load per page (default: 25) */
+    pageSize?: number;
+    /** Placeholder text for the search input */
+    placeholder?: string;
+    /** Whether to enable multi-select mode */
+    multiple?: boolean;
+    /** Custom filter function for search */
+    buildSearchFilter?: (searchTerm: string) => any;
+    /** Custom label renderer function for rich display */
+    label?: (item: T) => React.ReactNode;
+}
+```
+
+## Rich Label Display
+
+The `label` prop allows you to customize how items are displayed in both the dropdown and selected item chips. This enables rich content like images, badges, and multi-line information.
+
+### Product Selector with Images and Pricing
+
+```tsx title="src/plugins/my-plugin/dashboard/components/rich-product-selector.tsx"
+import {
+    SingleRelationInput,
+    createRelationSelectorConfig,
+    graphql,
+    ResultOf,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+const productListQuery = graphql(`
+    query GetProductsWithDetails($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                name
+                slug
+                featuredAsset {
+                    id
+                    preview
+                }
+                variants {
+                    id
+                    price
+                    currencyCode
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+const richProductConfig = createRelationSelectorConfig<
+    ResultOf<typeof productListQuery>['products']['items'][0]
+>({
+    listQuery: productListQuery,
+    idKey: 'id',
+    labelKey: 'name', // Used as fallback
+    placeholder: 'Search products...',
+    label: product => (
+        <div className="flex items-center gap-3 py-1">
+            {product.featuredAsset?.preview && (
+                <img
+                    src={product.featuredAsset.preview}
+                    alt={product.name}
+                    className="w-10 h-10 rounded object-cover"
+                />
+            )}
+            <div className="flex-1 min-w-0">
+                <div className="font-medium truncate">{product.name}</div>
+                <div className="text-sm text-muted-foreground">
+                    {product.variants[0] && (
+                        <span>
+                            {product.variants[0].price / 100} {product.variants[0].currencyCode}
+                        </span>
+                    )}
+                </div>
+            </div>
+        </div>
+    ),
+    buildSearchFilter: (term: string) => ({
+        name: { contains: term },
+    }),
+});
+
+export function RichProductSelectorComponent({ value, onChange, disabled }: CustomFormComponentInputProps) {
+    return (
+        <SingleRelationInput
+            value={value}
+            onChange={onChange}
+            config={richProductConfig}
+            disabled={disabled}
+        />
+    );
+}
+```
+
+### Customer Selector with Status Badges
+
+```tsx title="src/plugins/my-plugin/dashboard/components/customer-selector-with-status.tsx"
+import {
+    MultiRelationInput,
+    createRelationSelectorConfig,
+    graphql,
+    ResultOf,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+const customerListQuery = graphql(`
+    query GetCustomersWithStatus($options: CustomerListOptions) {
+        customers(options: $options) {
+            items {
+                id
+                firstName
+                lastName
+                emailAddress
+                user {
+                    verified
+                    lastLogin
+                }
+                orders {
+                    totalQuantity
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+const customerConfig = createRelationSelectorConfig<
+    ResultOf<typeof customerListQuery>['customers']['items'][0]
+>({
+    listQuery: customerListQuery,
+    idKey: 'id',
+    labelKey: 'emailAddress',
+    placeholder: 'Search customers...',
+    label: customer => (
+        <div className="flex items-center justify-between py-1 w-full">
+            <div className="flex-1 min-w-0">
+                <div className="font-medium truncate">
+                    {customer.firstName} {customer.lastName}
+                </div>
+                <div className="text-sm text-muted-foreground truncate">{customer.emailAddress}</div>
+            </div>
+            <div className="flex items-center gap-2 ml-2">
+                {customer.user?.verified ? (
+                    <span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
+                        Verified
+                    </span>
+                ) : (
+                    <span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800">
+                        Unverified
+                    </span>
+                )}
+                <span className="text-xs text-muted-foreground">{customer.orders.totalQuantity} orders</span>
+            </div>
+        </div>
+    ),
+    buildSearchFilter: (term: string) => ({
+        or: [
+            { emailAddress: { contains: term } },
+            { firstName: { contains: term } },
+            { lastName: { contains: term } },
+        ],
+    }),
+});
+
+export function CustomerSelectorWithStatusComponent({
+    value,
+    onChange,
+    disabled,
+}: CustomFormComponentInputProps) {
+    return (
+        <MultiRelationInput
+            value={value || []}
+            onChange={onChange}
+            config={customerConfig}
+            disabled={disabled}
+        />
+    );
+}
+```
+
+## Advanced Examples
+
+### Custom Entity with Complex Search
+
+```tsx title="src/plugins/review-plugin/dashboard/components/review-selector.tsx"
+import {
+    SingleRelationInput,
+    createRelationSelectorConfig,
+    graphql,
+    ResultOf,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+const reviewFragment = graphql(`
+    fragment ReviewForSelector on ProductReview {
+        id
+        title
+        rating
+        summary
+        state
+        product {
+            name
+        }
+    }
+`);
+
+const reviewListQuery = graphql(
+    `
+        query GetReviewsForSelection($options: ProductReviewListOptions) {
+            productReviews(options: $options) {
+                items {
+                    ...ReviewForSelector
+                }
+                totalItems
+            }
+        }
+    `,
+    [reviewFragment],
+);
+
+const reviewConfig = createRelationSelectorConfig<ResultOf<typeof reviewFragment>>({
+    listQuery: reviewListQuery,
+    idKey: 'id',
+    labelKey: 'title',
+    placeholder: 'Search reviews by title or summary...',
+    pageSize: 20, // Custom page size
+    buildSearchFilter: (term: string) => ({
+        // Search across multiple fields
+        or: [
+            { title: { contains: term } },
+            { summary: { contains: term } },
+            { product: { name: { contains: term } } },
+        ],
+    }),
+});
+
+export function ReviewSelectorComponent({ value, onChange }: CustomFormComponentInputProps) {
+    return <SingleRelationInput value={value} onChange={onChange} config={reviewConfig} />;
+}
+```
+
+### Asset Selector with Type Filtering
+
+```tsx title="src/plugins/my-plugin/dashboard/components/image-selector.tsx"
+import {
+    graphql,
+    createRelationSelectorConfig,
+    SingleRelationInput,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+const assetListQuery = graphql(`
+    query GetAssetsForSelection($options: AssetListOptions) {
+        assets(options: $options) {
+            items {
+                id
+                name
+                preview
+                type
+                fileSize
+            }
+            totalItems
+        }
+    }
+`);
+
+const imageAssetConfig = createRelationSelectorConfig({
+    listQuery: assetListQuery,
+    idKey: 'id',
+    labelKey: 'name',
+    placeholder: 'Search images...',
+    buildSearchFilter: (term: string) => ({
+        and: [
+            { type: { eq: 'IMAGE' } }, // Only show images
+            {
+                or: [{ name: { contains: term } }, { preview: { contains: term } }],
+            },
+        ],
+    }),
+});
+
+export function ImageSelectorComponent({ value, onChange }: CustomFormComponentInputProps) {
+    return <SingleRelationInput value={value} onChange={onChange} config={imageAssetConfig} />;
+}
+```
+
+### Multi-Select with Status Filtering
+
+```tsx title="src/plugins/my-plugin/dashboard/components/active-customer-selector.tsx"
+import {
+    MultiRelationInput,
+    createRelationSelectorConfig,
+    graphql,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+const customerListQuery = graphql(`
+    query GetCustomersForSelection($options: CustomerListOptions) {
+        customers(options: $options) {
+            items {
+                id
+                firstName
+                lastName
+                emailAddress
+                user {
+                    verified
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+const activeCustomerConfig = createRelationSelectorConfig({
+    listQuery: customerListQuery,
+    idKey: 'id',
+    labelKey: 'emailAddress',
+    placeholder: 'Search verified customers...',
+    pageSize: 30,
+    buildSearchFilter: (term: string) => ({
+        and: [
+            { user: { verified: { eq: true } } }, // Only verified customers
+            {
+                or: [
+                    { emailAddress: { contains: term } },
+                    { firstName: { contains: term } },
+                    { lastName: { contains: term } },
+                ],
+            },
+        ],
+    }),
+});
+
+export function ActiveCustomerSelectorComponent({ value, onChange }: CustomFormComponentInputProps) {
+    return <MultiRelationInput value={value || []} onChange={onChange} config={activeCustomerConfig} />;
+}
+```
+
+## Registration
+
+Register your relation selector components in your dashboard extension:
+
+```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+import { defineDashboardExtension } from '@vendure/dashboard';
+import {
+    ProductSelectorComponent,
+    ReviewSelectorComponent,
+    ImageSelectorComponent,
+    ActiveCustomerSelectorComponent,
+} from './components';
+
+export default defineDashboardExtension({
+    detailForms: [
+        {
+            pageId: 'product-detail',
+            inputs: [
+                {
+                    blockId: 'product-form',
+                    field: 'featuredProductId',
+                    component: ProductSelectorComponent,
+                },
+                {
+                    blockId: 'product-form',
+                    field: 'relatedCustomerIds',
+                    component: ActiveCustomerSelectorComponent,
+                },
+            ],
+        },
+        {
+            pageId: 'collection-detail',
+            inputs: [
+                {
+                    blockId: 'collection-form',
+                    field: 'featuredImageId',
+                    component: ImageSelectorComponent,
+                },
+                {
+                    blockId: 'collection-form',
+                    field: 'featuredReviewId',
+                    component: ReviewSelectorComponent,
+                },
+            ],
+        },
+    ],
+});
+```
+
+## Built-in Configurations
+
+The relation selector package includes pre-configured setups for common Vendure entities:
+
+```tsx
+import {
+    productRelationConfig,
+    customerRelationConfig,
+    collectionRelationConfig,
+    SingleRelationInput,
+    MultiRelationInput,
+    CustomFormComponentInputProps,
+} from '@vendure/dashboard';
+
+// Use pre-built configurations
+export function QuickProductSelector({ value, onChange }: CustomFormComponentInputProps) {
+    return <SingleRelationInput value={value} onChange={onChange} config={productRelationConfig} />;
+}
+
+export function QuickCustomerMultiSelector({ value, onChange }: CustomFormComponentInputProps) {
+    return <MultiRelationInput value={value || []} onChange={onChange} config={customerRelationConfig} />;
+}
+```
+
+## Best Practices
+
+### Query Optimization
+
+1. **Select only needed fields**: Include only the fields you actually use to improve performance
+2. **Use fragments**: Create reusable fragments for consistent data fetching
+3. **Optimize search filters**: Use database indexes for the fields you search on
+
+```tsx
+// Good: Minimal required fields
+const productListQuery = graphql(`
+    query GetProductsForSelection($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                name
+                # Only include what you need
+            }
+            totalItems
+        }
+    }
+`);
+
+// Avoid: Over-fetching unnecessary data
+const productListQuery = graphql(`
+    query GetProductsForSelection($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                name
+                description
+                featuredAsset { ... } # Only if you display it
+                variants { ... }      # Usually not needed for selection
+                # etc.
+            }
+            totalItems
+        }
+    }
+`);
+```
+
+### Performance Tips
+
+1. **Appropriate page sizes**: Balance between fewer requests and faster initial loads
+2. **Debounced search**: The default 300ms debounce prevents excessive API calls
+3. **Caching**: Queries are automatically cached by TanStack Query
+
+```tsx
+const config = createRelationSelectorConfig({
+    listQuery: myQuery,
+    idKey: 'id',
+    labelKey: 'name',
+    pageSize: 25, // Good default, adjust based on your data
+    buildSearchFilter: (term: string) => ({
+        // Use indexed fields for better performance
+        name: { contains: term },
+    }),
+});
+```
+
+### Type Safety
+
+Leverage TypeScript generics for full type safety:
+
+```tsx
+interface MyEntity {
+    id: string;
+    title: string;
+    status: 'ACTIVE' | 'INACTIVE';
+}
+
+const myEntityConfig = createRelationSelectorConfig<MyEntity>({
+    listQuery: myEntityQuery,
+    idKey: 'id', // ✅ TypeScript knows this must be a key of MyEntity
+    labelKey: 'title', // ✅ TypeScript validates this field exists
+    buildSearchFilter: (term: string) => ({
+        title: { contains: term }, // ✅ Auto-completion and validation
+    }),
+});
+```
+
+### Rich Label Design
+
+When using the `label` prop for custom rendering:
+
+1. **Keep it simple**: Avoid overly complex layouts that might impact performance
+2. **Handle missing data**: Always check for optional fields before rendering
+3. **Maintain accessibility**: Use proper semantic HTML and alt text for images
+4. **Consider mobile**: Ensure labels work well on smaller screens
+
+```tsx
+// Good: Simple, robust label design
+label: item => (
+    <div className="flex items-center gap-2">
+        {item.image && <img src={item.image} alt={item.name} className="w-8 h-8 rounded object-cover" />}
+        <div className="flex-1 min-w-0">
+            <div className="font-medium truncate">{item.name}</div>
+            {item.status && <div className="text-sm text-muted-foreground">{item.status}</div>}
+        </div>
+    </div>
+);
+
+// Avoid: Overly complex layouts
+label: item => (
+    <div className="complex-grid-layout-with-many-nested-elements">
+        {/* Too much complexity can hurt performance */}
+    </div>
+);
+```
+
+## Troubleshooting
+
+### Common Issues
+
+**1. "Cannot query field X on type Query"**
+
+```
+Error: Cannot query field "myEntities" on type "Query"
+```
+
+**Solution**: Ensure your GraphQL query field name matches your schema definition exactly.
+
+**2. Empty results despite data existing**
+
+```tsx
+// Problem: Wrong field used for search
+buildSearchFilter: (term: string) => ({
+    wrongField: { contains: term }, // This field doesn't exist
+});
+
+// Solution: Use correct field names
+buildSearchFilter: (term: string) => ({
+    name: { contains: term }, // Correct field name
+});
+```
+
+**3. TypeScript errors with config**
+
+```tsx
+// Problem: Missing type parameter
+const config = createRelationSelectorConfig({
+    // TypeScript can't infer the entity type
+});
+
+// Solution: Provide explicit type or use proper typing
+const config = createRelationSelectorConfig<MyEntityType>({
+    // Now TypeScript knows the shape of your entity
+});
+```
+
+### Performance Issues
+
+If you experience slow loading:
+
+1. **Check your GraphQL query**: Ensure it's optimized and uses appropriate filters
+2. **Verify database indexes**: Make sure searched fields are indexed
+3. **Adjust page size**: Try smaller page sizes for faster initial loads
+4. **Optimize buildSearchFilter**: Use efficient query patterns
+
+```tsx
+// Efficient search filter
+buildSearchFilter: (term: string) => ({
+    name: { contains: term }, // Simple, indexed field
+});
+
+// Less efficient
+buildSearchFilter: (term: string) => ({
+    or: [
+        { name: { contains: term } },
+        { description: { contains: term } },
+        { deepNestedField: { someComplexFilter: term } }, // Avoid deep nesting
+    ],
+});
+```

+ 1 - 0
docs/sidebars.js

@@ -150,6 +150,7 @@ const sidebars = {
                     items: [
                         'guides/extending-the-dashboard/custom-form-components/input-components',
                         'guides/extending-the-dashboard/custom-form-components/display-components',
+                        'guides/extending-the-dashboard/custom-form-components/relation-selectors',
                     ],
                 },
                 'guides/extending-the-dashboard/tech-stack/index',

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

@@ -0,0 +1,11 @@
+// Existing data input components
+export * from './affixed-input.js';
+export * from './customer-group-input.js';
+export * from './datetime-input.js';
+export * from './facet-value-input.js';
+export * from './money-input.js';
+export * from './rich-text-input.js';
+
+// Relation selector components
+export * from './relation-input.js';
+export * from './relation-selector.js';

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

@@ -0,0 +1,156 @@
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { createRelationSelectorConfig, RelationSelector } from './relation-selector.js';
+
+// Re-export for convenience
+export { createRelationSelectorConfig };
+
+/**
+ * Single relation input component
+ */
+export interface SingleRelationInputProps<T = any> {
+    value: string;
+    onChange: (value: string) => void;
+    config: Parameters<typeof createRelationSelectorConfig<T>>[0];
+    disabled?: boolean;
+    className?: string;
+}
+
+export function SingleRelationInput<T>({
+    value,
+    onChange,
+    config,
+    disabled,
+    className,
+}: Readonly<SingleRelationInputProps<T>>) {
+    const singleConfig = createRelationSelectorConfig<T>({
+        ...config,
+        multiple: false,
+    });
+
+    return (
+        <RelationSelector
+            config={singleConfig}
+            value={value}
+            onChange={newValue => onChange(newValue as string)}
+            disabled={disabled}
+            className={className}
+        />
+    );
+}
+
+/**
+ * Multi relation input component
+ */
+export interface MultiRelationInputProps<T = any> {
+    value: string[];
+    onChange: (value: string[]) => void;
+    config: Parameters<typeof createRelationSelectorConfig<T>>[0];
+    disabled?: boolean;
+    className?: string;
+}
+
+export function MultiRelationInput<T>({
+    value,
+    onChange,
+    config,
+    disabled,
+    className,
+}: Readonly<MultiRelationInputProps<T>>) {
+    const multiConfig = createRelationSelectorConfig<T>({
+        ...config,
+        multiple: true,
+    });
+
+    return (
+        <RelationSelector
+            config={multiConfig}
+            value={value}
+            onChange={newValue => onChange(newValue as string[])}
+            disabled={disabled}
+            className={className}
+        />
+    );
+}
+
+// Example configurations for common entities
+
+/**
+ * Product relation selector configuration
+ */
+export const productRelationConfig = createRelationSelectorConfig({
+    listQuery: graphql(`
+        query GetProductsForRelationSelector($options: ProductListOptions) {
+            products(options: $options) {
+                items {
+                    id
+                    name
+                    slug
+                    featuredAsset {
+                        id
+                        preview
+                    }
+                }
+                totalItems
+            }
+        }
+    `),
+    idKey: 'id' as const,
+    labelKey: 'name' as const,
+    placeholder: 'Search products...',
+    buildSearchFilter: (term: string) => ({
+        name: { contains: term },
+    }),
+});
+
+/**
+ * Customer relation selector configuration
+ */
+export const customerRelationConfig = createRelationSelectorConfig({
+    listQuery: graphql(`
+        query GetCustomersForRelationSelector($options: CustomerListOptions) {
+            customers(options: $options) {
+                items {
+                    id
+                    firstName
+                    lastName
+                    emailAddress
+                }
+                totalItems
+            }
+        }
+    `),
+    idKey: 'id' as const,
+    labelKey: 'emailAddress' as const,
+    placeholder: 'Search customers...',
+    buildSearchFilter: (term: string) => ({
+        emailAddress: { contains: term },
+    }),
+});
+
+/**
+ * Collection relation selector configuration
+ */
+export const collectionRelationConfig = createRelationSelectorConfig({
+    listQuery: graphql(`
+        query GetCollectionsForRelationSelector($options: CollectionListOptions) {
+            collections(options: $options) {
+                items {
+                    id
+                    name
+                    slug
+                    featuredAsset {
+                        id
+                        preview
+                    }
+                }
+                totalItems
+            }
+        }
+    `),
+    idKey: 'id' as const,
+    labelKey: 'name' as const,
+    placeholder: 'Search collections...',
+    buildSearchFilter: (term: string) => ({
+        name: { contains: term },
+    }),
+});

+ 350 - 0
packages/dashboard/src/lib/components/data-input/relation-selector.tsx

@@ -0,0 +1,350 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { Checkbox } from '@/vdb/components/ui/checkbox.js';
+import {
+    Command,
+    CommandEmpty,
+    CommandInput,
+    CommandItem,
+    CommandList,
+} from '@/vdb/components/ui/command.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { getQueryName } from '@/vdb/framework/document-introspection/get-document-structure.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import type { DocumentNode } from 'graphql';
+import { CheckIcon, Loader2, Plus, X } from 'lucide-react';
+import React, { useState } from 'react';
+
+export interface RelationSelectorConfig<T = any> {
+    /** The GraphQL query document for fetching items */
+    listQuery: DocumentNode;
+    /** The property key for the entity ID */
+    idKey: keyof T;
+    /** The property key for the display label */
+    labelKey: keyof T;
+    /** Number of items to load per page */
+    pageSize?: number;
+    /** Placeholder text for the search input */
+    placeholder?: string;
+    /** Whether to enable multi-select mode */
+    multiple?: boolean;
+    /** Custom filter function for search */
+    buildSearchFilter?: (searchTerm: string) => any;
+    /** Custom label renderer function for rich display */
+    label?: (item: T) => React.ReactNode;
+}
+
+export interface RelationSelectorProps<T = any> {
+    config: RelationSelectorConfig<T>;
+    value?: string | string[];
+    onChange: (value: string | string[]) => void;
+    disabled?: boolean;
+    className?: string;
+}
+
+export interface RelationSelectorItemProps<T = any> {
+    item: T;
+    config: RelationSelectorConfig<T>;
+    isSelected: boolean;
+    onSelect: () => void;
+    showCheckbox?: boolean;
+}
+
+/**
+ * Abstract relation selector item component
+ */
+export function RelationSelectorItem<T>({
+    item,
+    config,
+    isSelected,
+    onSelect,
+    showCheckbox = false,
+}: Readonly<RelationSelectorItemProps<T>>) {
+    const id = String(item[config.idKey]);
+    const label = config.label ? config.label(item) : String(item[config.labelKey]);
+
+    return (
+        <CommandItem key={id} value={id} onSelect={onSelect} className="flex items-center gap-2">
+            {showCheckbox && (
+                <Checkbox
+                    checked={isSelected}
+                    onChange={onSelect}
+                    onClick={(e: React.MouseEvent) => e.stopPropagation()}
+                />
+            )}
+            {isSelected && !showCheckbox && <CheckIcon className="h-4 w-4" />}
+            <span className="flex-1">{label}</span>
+        </CommandItem>
+    );
+}
+
+/**
+ * Hook for managing relation selector state and queries
+ */
+export function useRelationSelector<T>(config: RelationSelectorConfig<T>) {
+    const [searchTerm, setSearchTerm] = useState('');
+    const debouncedSearch = useDebounce(searchTerm, 300);
+
+    const pageSize = config.pageSize ?? 25;
+
+    // Build the default search filter if none provided
+    const buildFilter =
+        config.buildSearchFilter ??
+        ((term: string) => ({
+            [config.labelKey]: { contains: term },
+        }));
+
+    const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, error } = useInfiniteQuery({
+        queryKey: ['relationSelector', getQueryName(config.listQuery), debouncedSearch],
+        queryFn: async ({ pageParam = 0 }) => {
+            const variables: any = {
+                options: {
+                    skip: pageParam * pageSize,
+                    take: pageSize,
+                    sort: { [config.labelKey]: 'ASC' },
+                },
+            };
+
+            // Add search filter if there's a search term
+            if (debouncedSearch.trim().length > 0) {
+                variables.options.filter = buildFilter(debouncedSearch.trim());
+            }
+
+            const response = (await api.query(config.listQuery, variables)) as any;
+            return response[getQueryName(config.listQuery)];
+        },
+        getNextPageParam: (lastPage, allPages) => {
+            if (!lastPage) return undefined;
+            const totalFetched = allPages.length * pageSize;
+            return totalFetched < lastPage.totalItems ? allPages.length : undefined;
+        },
+        initialPageParam: 0,
+    });
+
+    const items = data?.pages.flatMap(page => page?.items ?? []) ?? [];
+
+    return {
+        items,
+        isLoading,
+        fetchNextPage,
+        hasNextPage,
+        isFetchingNextPage,
+        error,
+        searchTerm,
+        setSearchTerm,
+    };
+}
+
+/**
+ * Abstract relation selector component
+ */
+export function RelationSelector<T>({
+    config,
+    value,
+    onChange,
+    disabled,
+    className,
+}: Readonly<RelationSelectorProps<T>>) {
+    const [open, setOpen] = useState(false);
+    const [selectedItemsCache, setSelectedItemsCache] = useState<T[]>([]);
+    const isMultiple = config.multiple ?? false;
+
+    const { items, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, searchTerm, setSearchTerm } =
+        useRelationSelector(config);
+
+    // Normalize value to always be an array for easier handling
+    const selectedIds = React.useMemo(() => {
+        if (isMultiple) {
+            return Array.isArray(value) ? value : value ? [value] : [];
+        }
+        return value ? [String(value)] : [];
+    }, [value, isMultiple]);
+
+    const handleSelect = (item: T) => {
+        const itemId = String(item[config.idKey]);
+
+        if (isMultiple) {
+            const isCurrentlySelected = selectedIds.includes(itemId);
+            const newSelectedIds = isCurrentlySelected
+                ? selectedIds.filter(id => id !== itemId)
+                : [...selectedIds, itemId];
+
+            // Update cache: add item if selecting, remove if deselecting
+            setSelectedItemsCache(prev => {
+                if (isCurrentlySelected) {
+                    return prev.filter(prevItem => String(prevItem[config.idKey]) !== itemId);
+                } else {
+                    // Only add if not already in cache
+                    const alreadyInCache = prev.some(prevItem => String(prevItem[config.idKey]) === itemId);
+                    return alreadyInCache ? prev : [...prev, item];
+                }
+            });
+
+            onChange(newSelectedIds);
+        } else {
+            // For single select, update cache with the new item
+            setSelectedItemsCache([item]);
+            onChange(itemId);
+            setOpen(false);
+            setSearchTerm('');
+        }
+    };
+
+    const handleRemove = (itemId: string) => {
+        if (isMultiple) {
+            const newSelectedIds = selectedIds.filter(id => id !== itemId);
+            // Remove from cache as well
+            setSelectedItemsCache(prev => prev.filter(prevItem => String(prevItem[config.idKey]) !== itemId));
+            onChange(newSelectedIds);
+        } else {
+            // Clear cache for single select
+            setSelectedItemsCache([]);
+            onChange('');
+        }
+    };
+
+    const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
+        const target = e.currentTarget;
+        const scrolledToBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 1;
+
+        if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
+            fetchNextPage();
+        }
+    };
+
+    // Clean up cache when selectedIds change externally (e.g., form reset)
+    React.useEffect(() => {
+        setSelectedItemsCache(prev => prev.filter(item => selectedIds.includes(String(item[config.idKey]))));
+    }, [selectedIds, config.idKey]);
+
+    // Populate cache with items from search results that are selected but not yet cached
+    React.useEffect(() => {
+        const itemsToAdd = items.filter(item => {
+            const itemId = String(item[config.idKey]);
+            const isSelected = selectedIds.includes(itemId);
+            const isAlreadyCached = selectedItemsCache.some(
+                cachedItem => String(cachedItem[config.idKey]) === itemId,
+            );
+            return isSelected && !isAlreadyCached;
+        });
+
+        if (itemsToAdd.length > 0) {
+            setSelectedItemsCache(prev => [...prev, ...itemsToAdd]);
+        }
+    }, [items, selectedIds, selectedItemsCache, config.idKey]);
+
+    // Get selected items for display from cache, filtered by current selection
+    const selectedItems = React.useMemo(() => {
+        return selectedItemsCache.filter(item => selectedIds.includes(String(item[config.idKey])));
+    }, [selectedItemsCache, selectedIds, config.idKey]);
+
+    return (
+        <div className={className}>
+            {/* Display selected items */}
+            {selectedItems.length > 0 && (
+                <div className="flex flex-wrap gap-2 mb-2">
+                    {selectedItems.map(item => {
+                        const itemId = String(item[config.idKey]);
+                        const label = config.label ? config.label(item) : String(item[config.labelKey]);
+                        return (
+                            <div
+                                key={itemId}
+                                className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
+                            >
+                                <span>{label}</span>
+                                {!disabled && (
+                                    <button
+                                        type="button"
+                                        onClick={() => handleRemove(itemId)}
+                                        className="text-secondary-foreground/70 hover:text-secondary-foreground"
+                                    >
+                                        <X className="h-3 w-3" />
+                                    </button>
+                                )}
+                            </div>
+                        );
+                    })}
+                </div>
+            )}
+
+            {/* Selector trigger */}
+            <Popover open={open} onOpenChange={setOpen}>
+                <PopoverTrigger asChild>
+                    <Button variant="outline" size="sm" type="button" disabled={disabled} className="gap-2">
+                        <Plus className="h-4 w-4" />
+                        <Trans>
+                            {isMultiple
+                                ? selectedItems.length > 0
+                                    ? `Add more (${selectedItems.length} selected)`
+                                    : 'Select items'
+                                : selectedItems.length > 0
+                                  ? 'Change selection'
+                                  : 'Select item'}
+                        </Trans>
+                    </Button>
+                </PopoverTrigger>
+                <PopoverContent className="p-0 w-[400px]" align="start">
+                    <Command shouldFilter={false}>
+                        <CommandInput
+                            placeholder={config.placeholder ?? 'Search...'}
+                            value={searchTerm}
+                            onValueChange={setSearchTerm}
+                            disabled={disabled}
+                        />
+                        <CommandList className="h-[300px] overflow-y-auto" onScroll={handleScroll}>
+                            <CommandEmpty>
+                                {isLoading ? (
+                                    <div className="flex items-center justify-center py-6">
+                                        <Loader2 className="h-4 w-4 animate-spin mr-2" />
+                                        <Trans>Loading...</Trans>
+                                    </div>
+                                ) : (
+                                    <Trans>No results found</Trans>
+                                )}
+                            </CommandEmpty>
+
+                            {items.map(item => {
+                                const itemId = String(item[config.idKey]);
+                                const isSelected = selectedIds.includes(itemId);
+
+                                return (
+                                    <RelationSelectorItem
+                                        key={itemId}
+                                        item={item}
+                                        config={config}
+                                        isSelected={isSelected}
+                                        onSelect={() => handleSelect(item)}
+                                        showCheckbox={isMultiple}
+                                    />
+                                );
+                            })}
+
+                            {(isFetchingNextPage || isLoading) && (
+                                <div className="flex items-center justify-center py-2">
+                                    <Loader2 className="h-4 w-4 animate-spin" />
+                                </div>
+                            )}
+
+                            {!hasNextPage && items.length > 0 && (
+                                <div className="text-center py-2 text-sm text-muted-foreground">
+                                    <Trans>No more items</Trans>
+                                </div>
+                            )}
+                        </CommandList>
+                    </Command>
+                </PopoverContent>
+            </Popover>
+        </div>
+    );
+}
+
+/**
+ * Utility function to create a relation selector configuration
+ */
+export function createRelationSelectorConfig<T>(
+    config: Readonly<RelationSelectorConfig<T>>,
+): RelationSelectorConfig<T> {
+    return config;
+}

+ 0 - 0
packages/dashboard/src/lib/components/data-input/richt-text-input.tsx → packages/dashboard/src/lib/components/data-input/rich-text-input.tsx


+ 7 - 4
packages/dashboard/src/lib/index.ts

@@ -8,7 +8,10 @@ export * from './components/data-input/affixed-input.js';
 export * from './components/data-input/customer-group-input.js';
 export * from './components/data-input/datetime-input.js';
 export * from './components/data-input/facet-value-input.js';
+export * from './components/data-input/index.js';
 export * from './components/data-input/money-input.js';
+export * from './components/data-input/relation-input.js';
+export * from './components/data-input/relation-selector.js';
 export * from './components/data-input/richt-text-input.js';
 export * from './components/data-table/add-filter-menu.js';
 export * from './components/data-table/data-table-bulk-action-item.js';
@@ -184,10 +187,6 @@ export * from './framework/page/use-detail-page.js';
 export * from './framework/page/use-extended-router.js';
 export * from './framework/registry/global-registry.js';
 export * from './framework/registry/registry-types.js';
-export * from './graphql/api.js';
-export * from './graphql/common-operations.js';
-export * from './graphql/fragments.js';
-export * from './graphql/graphql.js';
 export * from './hooks/use-auth.js';
 export * from './hooks/use-channel.js';
 export * from './hooks/use-custom-field-config.js';
@@ -204,3 +203,7 @@ export * from './hooks/use-theme.js';
 export * from './hooks/use-user-settings.js';
 export * from './lib/trans.js';
 export * from './lib/utils.js';
+export * from './graphql/api.js';
+export * from './graphql/common-operations.js';
+export * from './graphql/fragments.js';
+export * from './graphql/graphql.js';

+ 58 - 0
packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx

@@ -2,8 +2,13 @@ import {
     CustomFormComponentInputProps,
     DataDisplayComponentProps,
     DataInputComponentProps,
+    MultiRelationInput,
+    RelationSelectorConfig,
+    ResultOf,
+    SingleRelationInput,
     Textarea,
 } from '@vendure/dashboard';
+import { graphql } from '../../../graphql/graphql';
 
 export function TextareaCustomField({ field }: CustomFormComponentInputProps) {
     return <Textarea {...field} rows={4} />;
@@ -16,3 +21,56 @@ export function ResponseDisplay({ value }: DataDisplayComponentProps) {
 export function BodyInputComponent(props: DataInputComponentProps) {
     return <Textarea {...props} rows={4} />;
 }
+
+const reviewFragment = graphql(`
+    fragment Review on ProductReview {
+        id
+        summary
+    }
+`);
+
+const reviewListQuery = graphql(
+    `
+        query GetReviewList($options: ProductReviewListOptions) {
+            productReviews(options: $options) {
+                items {
+                    ...Review
+                }
+                totalItems
+            }
+        }
+    `,
+    [reviewFragment],
+);
+
+export function ReviewSingleSelect(props: CustomFormComponentInputProps) {
+    const config: RelationSelectorConfig<ResultOf<typeof reviewFragment>> = {
+        listQuery: reviewListQuery,
+        labelKey: 'summary',
+        idKey: 'id',
+    };
+
+    return (
+        <SingleRelationInput
+            value={props.field.value}
+            onChange={props.field.onChange}
+            config={config}
+        ></SingleRelationInput>
+    );
+}
+
+export function ReviewMultiSelect(props: CustomFormComponentInputProps) {
+    const config: RelationSelectorConfig<ResultOf<typeof reviewFragment>> = {
+        listQuery: reviewListQuery,
+        labelKey: 'summary',
+        idKey: 'id',
+    };
+
+    return (
+        <MultiRelationInput
+            value={props.field.value}
+            onChange={props.field.onChange}
+            config={config}
+        ></MultiRelationInput>
+    );
+}

+ 15 - 1
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -2,7 +2,13 @@ import { Button, DataTableBulkActionItem, defineDashboardExtension, usePage } fr
 import { InfoIcon } from 'lucide-react';
 import { toast } from 'sonner';
 
-import { BodyInputComponent, ResponseDisplay, TextareaCustomField } from './custom-form-components';
+import {
+    BodyInputComponent,
+    ResponseDisplay,
+    ReviewMultiSelect,
+    ReviewSingleSelect,
+    TextareaCustomField,
+} from './custom-form-components';
 import { CustomWidget } from './custom-widget';
 import { reviewDetail } from './review-detail';
 import { reviewList } from './review-list';
@@ -55,6 +61,14 @@ defineDashboardExtension({
                 id: 'textarea',
                 component: TextareaCustomField,
             },
+            {
+                id: 'review-single-select',
+                component: ReviewSingleSelect,
+            },
+            {
+                id: 'review-multi-select',
+                component: ReviewMultiSelect,
+            },
         ],
     },
     detailForms: [

+ 2 - 2
packages/dev-server/test-plugins/reviews/reviews-plugin.ts

@@ -54,7 +54,7 @@ import { ProductReview } from './entities/product-review.entity';
             public: true,
             type: 'relation',
             entity: ProductReview,
-            ui: { tab: 'Reviews', fullWidth: true },
+            ui: { tab: 'Reviews', fullWidth: true, component: 'review-single-select' },
             inverseSide: undefined,
         });
         config.customFields.Product.push({
@@ -64,7 +64,7 @@ import { ProductReview } from './entities/product-review.entity';
             type: 'relation',
             list: true,
             entity: ProductReview,
-            ui: { tab: 'Reviews', fullWidth: true },
+            ui: { tab: 'Reviews', fullWidth: true, component: 'review-multi-select' },
         });
         config.customFields.Product.push({
             name: 'translatableText',