Преглед на файлове

feat(dashboard): Full support for configurable operations UI (#3702)

Michael Bromley преди 5 месеца
родител
ревизия
8e0c5b4310
променени са 19 файла, в които са добавени 1895 реда и са изтрити 639 реда
  1. 1 1
      packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-contents-preview-table.tsx
  2. 11 78
      packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-filters-selector.tsx
  3. 11 81
      packages/dashboard/src/app/routes/_authenticated/_payment-methods/components/payment-eligibility-checker-selector.tsx
  4. 10 77
      packages/dashboard/src/app/routes/_authenticated/_payment-methods/components/payment-handler-selector.tsx
  5. 12 87
      packages/dashboard/src/app/routes/_authenticated/_promotions/components/promotion-actions-selector.tsx
  6. 12 87
      packages/dashboard/src/app/routes/_authenticated/_promotions/components/promotion-conditions-selector.tsx
  7. 10 80
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-calculator-selector.tsx
  8. 10 79
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx
  9. 8 6
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx
  10. 52 0
      packages/dashboard/src/lib/components/data-input/combination-mode-input.tsx
  11. 433 0
      packages/dashboard/src/lib/components/data-input/configurable-operation-list-input.tsx
  12. 27 0
      packages/dashboard/src/lib/components/data-input/default-relation-input.tsx
  13. 5 0
      packages/dashboard/src/lib/components/data-input/index.ts
  14. 426 0
      packages/dashboard/src/lib/components/data-input/product-multi-selector.tsx
  15. 365 21
      packages/dashboard/src/lib/components/shared/configurable-operation-arg-input.tsx
  16. 81 41
      packages/dashboard/src/lib/components/shared/configurable-operation-input.tsx
  17. 260 0
      packages/dashboard/src/lib/components/shared/configurable-operation-multi-selector.tsx
  18. 156 0
      packages/dashboard/src/lib/components/shared/configurable-operation-selector.tsx
  19. 5 1
      packages/dashboard/src/lib/framework/extension-api/input-component-extensions.tsx

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-contents-preview-table.tsx

@@ -61,7 +61,7 @@ export function CollectionContentsPreviewTable({
 
     return (
         <div>
-            <Alert>
+            <Alert className="mb-4">
                 <Eye className="h-4 w-4" />
                 <AlertTitle>Preview</AlertTitle>
                 <AlertDescription>

+ 11 - 78
packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-filters-selector.tsx

@@ -1,17 +1,5 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { Separator } from '@/vdb/components/ui/separator.js';
-import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
+import { ConfigurableOperationMultiSelector } from '@/vdb/components/shared/configurable-operation-multi-selector.js';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 import { getCollectionFiltersQueryOptions } from '../collections.graphql.js';
 
 export interface CollectionFiltersSelectorProps {
@@ -20,72 +8,17 @@ export interface CollectionFiltersSelectorProps {
 }
 
 export function CollectionFiltersSelector({ value, onChange }: Readonly<CollectionFiltersSelectorProps>) {
-    const { data: filtersData } = useQuery(getCollectionFiltersQueryOptions);
-
-    const filters = filtersData?.collectionFilters;
-
-    const onFilterSelected = (filter: ConfigurableOperationDefFragment) => {
-        const filterDef = filters?.find(f => f.code === filter.code);
-        if (!filterDef) {
-            return;
-        }
-        onChange([
-            ...value,
-            {
-                code: filter.code,
-                arguments: filterDef.args.map(arg => ({
-                    name: arg.name,
-                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-                })),
-            },
-        ]);
-    };
-
-    const onOperationValueChange = (
-        filter: ConfigurableOperationInputType,
-        newVal: ConfigurableOperationInputType,
-    ) => {
-        onChange(value.map(f => (f.code === filter.code ? newVal : f)));
-    };
-
-    const onOperationRemove = (index: number) => {
-        onChange(value.filter((_, i) => i !== index));
-    };
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {(value ?? []).map((filter, index) => {
-                const filterDef = filters?.find(f => f.code === filter.code);
-                if (!filterDef) {
-                    return null;
-                }
-                return (
-                    <div key={index} className="flex flex-col gap-2">
-                        <ConfigurableOperationInput
-                            operationDefinition={filterDef}
-                            value={filter}
-                            onChange={value => onOperationValueChange(filter, value)}
-                            onRemove={() => onOperationRemove(index)}
-                        />
-                        <Separator className="my-2" />
-                    </div>
-                );
-            })}
-            <DropdownMenu>
-                <DropdownMenuTrigger asChild>
-                    <Button variant="outline">
-                        <Plus />
-                        <Trans context="Add new collection filter">Add condition</Trans>
-                    </Button>
-                </DropdownMenuTrigger>
-                <DropdownMenuContent className="w-96">
-                    {filters?.map(filter => (
-                        <DropdownMenuItem key={filter.code} onClick={() => onFilterSelected(filter)}>
-                            {filter.description}
-                        </DropdownMenuItem>
-                    ))}
-                </DropdownMenuContent>
-            </DropdownMenu>
+        <div className="mt-4">
+            <ConfigurableOperationMultiSelector
+                value={value}
+                onChange={onChange}
+                queryOptions={getCollectionFiltersQueryOptions}
+                queryKey="getCollectionFilters"
+                dataPath="collectionFilters"
+                buttonText="Add collection filter"
+                showEnhancedDropdown={false}
+            />
         </div>
     );
 }

+ 11 - 81
packages/dashboard/src/app/routes/_authenticated/_payment-methods/components/payment-eligibility-checker-selector.tsx

@@ -1,21 +1,7 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { api } from '@/vdb/graphql/api.js';
-import {
-    configurableOperationDefFragment,
-    ConfigurableOperationDefFragment,
-} from '@/vdb/graphql/fragments.js';
+import { ConfigurableOperationSelector } from '@/vdb/components/shared/configurable-operation-selector.js';
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 
 export const paymentEligibilityCheckersDocument = graphql(
     `
@@ -37,71 +23,15 @@ export function PaymentEligibilityCheckerSelector({
     value,
     onChange,
 }: PaymentEligibilityCheckerSelectorProps) {
-    const { data: checkersData } = useQuery({
-        queryKey: ['paymentMethodEligibilityCheckers'],
-        queryFn: () => api.query(paymentEligibilityCheckersDocument),
-        staleTime: 1000 * 60 * 60 * 5,
-    });
-
-    const checkers = checkersData?.paymentMethodEligibilityCheckers;
-
-    const onCheckerSelected = (checker: ConfigurableOperationDefFragment) => {
-        const checkerDef = checkers?.find(c => c.code === checker.code);
-        if (!checkerDef) {
-            return;
-        }
-        onChange({
-            code: checker.code,
-            arguments: checkerDef.args.map(arg => ({
-                name: arg.name,
-                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-            })),
-        });
-    };
-
-    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
-        onChange(newVal);
-    };
-
-    const onOperationRemove = () => {
-        onChange(undefined);
-    };
-
-    const checkerDef = checkers?.find(c => c.code === value?.code);
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {value && checkerDef && (
-                <div className="flex flex-col gap-2">
-                    <ConfigurableOperationInput
-                        operationDefinition={checkerDef}
-                        value={value}
-                        onChange={value => onOperationValueChange(value)}
-                        onRemove={() => onOperationRemove()}
-                    />
-                </div>
-            )}
-            <DropdownMenu>
-                {!value?.code && (
-                    <DropdownMenuTrigger asChild>
-                        <Button variant="outline">
-                            <Plus />
-                            <Trans>Select Payment Eligibility Checker</Trans>
-                        </Button>
-                    </DropdownMenuTrigger>
-                )}
-                <DropdownMenuContent className="w-96">
-                    {checkers?.length ? (
-                        checkers?.map(checker => (
-                            <DropdownMenuItem key={checker.code} onClick={() => onCheckerSelected(checker)}>
-                                {checker.description}
-                            </DropdownMenuItem>
-                        ))
-                    ) : (
-                        <DropdownMenuItem>No checkers found</DropdownMenuItem>
-                    )}
-                </DropdownMenuContent>
-            </DropdownMenu>
-        </div>
+        <ConfigurableOperationSelector
+            value={value}
+            onChange={onChange}
+            queryDocument={paymentEligibilityCheckersDocument}
+            queryKey="paymentMethodEligibilityCheckers"
+            dataPath="paymentMethodEligibilityCheckers"
+            buttonText="Select Payment Eligibility Checker"
+            emptyText="No checkers found"
+        />
     );
 }

+ 10 - 77
packages/dashboard/src/app/routes/_authenticated/_payment-methods/components/payment-handler-selector.tsx

@@ -1,21 +1,7 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { api } from '@/vdb/graphql/api.js';
-import {
-    configurableOperationDefFragment,
-    ConfigurableOperationDefFragment,
-} from '@/vdb/graphql/fragments.js';
+import { ConfigurableOperationSelector } from '@/vdb/components/shared/configurable-operation-selector.js';
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 
 export const paymentHandlersDocument = graphql(
     `
@@ -34,67 +20,14 @@ interface PaymentHandlerSelectorProps {
 }
 
 export function PaymentHandlerSelector({ value, onChange }: Readonly<PaymentHandlerSelectorProps>) {
-    const { data: handlersData } = useQuery({
-        queryKey: ['paymentMethodHandlers'],
-        queryFn: () => api.query(paymentHandlersDocument),
-        staleTime: 1000 * 60 * 60 * 5,
-    });
-
-    const handlers = handlersData?.paymentMethodHandlers;
-
-    const onHandlerSelected = (handler: ConfigurableOperationDefFragment) => {
-        const handlerDef = handlers?.find(h => h.code === handler.code);
-        if (!handlerDef) {
-            return;
-        }
-        onChange({
-            code: handler.code,
-            arguments: handlerDef.args.map(arg => ({
-                name: arg.name,
-                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-            })),
-        });
-    };
-
-    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
-        onChange(newVal);
-    };
-
-    const onOperationRemove = () => {
-        onChange(undefined);
-    };
-
-    const handlerDef = handlers?.find(h => h.code === value?.code);
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {value && handlerDef && (
-                <div className="flex flex-col gap-2">
-                    <ConfigurableOperationInput
-                        operationDefinition={handlerDef}
-                        value={value}
-                        onChange={value => onOperationValueChange(value)}
-                        onRemove={() => onOperationRemove()}
-                    />
-                </div>
-            )}
-            <DropdownMenu>
-                {!value?.code && (
-                    <DropdownMenuTrigger asChild>
-                        <Button variant="outline">
-                            <Plus />
-                            <Trans>Select Payment Handler</Trans>
-                        </Button>
-                    </DropdownMenuTrigger>
-                )}
-                <DropdownMenuContent className="w-96">
-                    {handlers?.map(handler => (
-                        <DropdownMenuItem key={handler.code} onClick={() => onHandlerSelected(handler)}>
-                            {handler.description}
-                        </DropdownMenuItem>
-                    ))}
-                </DropdownMenuContent>
-            </DropdownMenu>
-        </div>
+        <ConfigurableOperationSelector
+            value={value}
+            onChange={onChange}
+            queryDocument={paymentHandlersDocument}
+            queryKey="paymentMethodHandlers"
+            dataPath="paymentMethodHandlers"
+            buttonText="Select Payment Handler"
+        />
     );
 }

+ 12 - 87
packages/dashboard/src/app/routes/_authenticated/_promotions/components/promotion-actions-selector.tsx

@@ -1,22 +1,7 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { Separator } from '@/vdb/components/ui/separator.js';
-import { api } from '@/vdb/graphql/api.js';
-import {
-    configurableOperationDefFragment,
-    ConfigurableOperationDefFragment,
-} from '@/vdb/graphql/fragments.js';
+import { ConfigurableOperationMultiSelector } from '@/vdb/components/shared/configurable-operation-multi-selector.js';
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 
 export const promotionActionsDocument = graphql(
     `
@@ -35,76 +20,16 @@ interface PromotionActionsSelectorProps {
 }
 
 export function PromotionActionsSelector({ value, onChange }: Readonly<PromotionActionsSelectorProps>) {
-    const { data: actionsData } = useQuery({
-        queryKey: ['promotionActions'],
-        queryFn: () => api.query(promotionActionsDocument),
-        staleTime: 1000 * 60 * 60 * 5,
-    });
-
-    const actions = actionsData?.promotionActions;
-
-    const onActionSelected = (action: ConfigurableOperationDefFragment) => {
-        const actionDef = actions?.find(a => a.code === action.code);
-        if (!actionDef) {
-            return;
-        }
-        onChange([
-            ...value,
-            {
-                code: action.code,
-                arguments: actionDef.args.map(arg => ({
-                    name: arg.name,
-                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-                })),
-            },
-        ]);
-    };
-
-    const onOperationValueChange = (
-        action: ConfigurableOperationInputType,
-        newVal: ConfigurableOperationInputType,
-    ) => {
-        onChange(value.map(a => (a.code === action.code ? newVal : a)));
-    };
-
-    const onOperationRemove = (index: number) => {
-        onChange(value.filter((_, i) => i !== index));
-    };
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {(value ?? []).map((action, index) => {
-                const actionDef = actions?.find(a => a.code === action.code);
-                if (!actionDef) {
-                    return null;
-                }
-                return (
-                    <div key={index} className="flex flex-col gap-2">
-                        <ConfigurableOperationInput
-                            operationDefinition={actionDef}
-                            value={action}
-                            onChange={value => onOperationValueChange(action, value)}
-                            onRemove={() => onOperationRemove(index)}
-                        />
-                        <Separator className="my-2" />
-                    </div>
-                );
-            })}
-            <DropdownMenu>
-                <DropdownMenuTrigger asChild>
-                    <Button variant="outline">
-                        <Plus />
-                        <Trans context="Add new promotion action">Add action</Trans>
-                    </Button>
-                </DropdownMenuTrigger>
-                <DropdownMenuContent className="w-96">
-                    {actions?.map(action => (
-                        <DropdownMenuItem key={action.code} onClick={() => onActionSelected(action)}>
-                            {action.description}
-                        </DropdownMenuItem>
-                    ))}
-                </DropdownMenuContent>
-            </DropdownMenu>
-        </div>
+        <ConfigurableOperationMultiSelector
+            value={value}
+            onChange={onChange}
+            queryDocument={promotionActionsDocument}
+            queryKey="promotionActions"
+            dataPath="promotionActions"
+            buttonText="Add action"
+            dropdownTitle="Available Actions"
+            showEnhancedDropdown={true}
+        />
     );
 }

+ 12 - 87
packages/dashboard/src/app/routes/_authenticated/_promotions/components/promotion-conditions-selector.tsx

@@ -1,22 +1,7 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { Separator } from '@/vdb/components/ui/separator.js';
-import { api } from '@/vdb/graphql/api.js';
-import {
-    configurableOperationDefFragment,
-    ConfigurableOperationDefFragment,
-} from '@/vdb/graphql/fragments.js';
+import { ConfigurableOperationMultiSelector } from '@/vdb/components/shared/configurable-operation-multi-selector.js';
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 
 export const promotionConditionsDocument = graphql(
     `
@@ -35,76 +20,16 @@ interface PromotionConditionsSelectorProps {
 }
 
 export function PromotionConditionsSelector({ value, onChange }: Readonly<PromotionConditionsSelectorProps>) {
-    const { data: conditionsData } = useQuery({
-        queryKey: ['promotionConditions'],
-        queryFn: () => api.query(promotionConditionsDocument),
-        staleTime: 1000 * 60 * 60 * 5,
-    });
-
-    const conditions = conditionsData?.promotionConditions;
-
-    const onConditionSelected = (condition: ConfigurableOperationDefFragment) => {
-        const conditionDef = conditions?.find(c => c.code === condition.code);
-        if (!conditionDef) {
-            return;
-        }
-        onChange([
-            ...value,
-            {
-                code: condition.code,
-                arguments: conditionDef.args.map(arg => ({
-                    name: arg.name,
-                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-                })),
-            },
-        ]);
-    };
-
-    const onOperationValueChange = (
-        condition: ConfigurableOperationInputType,
-        newVal: ConfigurableOperationInputType,
-    ) => {
-        onChange(value.map(c => (c.code === condition.code ? newVal : c)));
-    };
-
-    const onOperationRemove = (index: number) => {
-        onChange(value.filter((_, i) => i !== index));
-    };
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {(value ?? []).map((condition, index) => {
-                const conditionDef = conditions?.find(c => c.code === condition.code);
-                if (!conditionDef) {
-                    return null;
-                }
-                return (
-                    <div key={index} className="flex flex-col gap-2">
-                        <ConfigurableOperationInput
-                            operationDefinition={conditionDef}
-                            value={condition}
-                            onChange={value => onOperationValueChange(condition, value)}
-                            onRemove={() => onOperationRemove(index)}
-                        />
-                        <Separator className="my-2" />
-                    </div>
-                );
-            })}
-            <DropdownMenu>
-                <DropdownMenuTrigger asChild>
-                    <Button variant="outline">
-                        <Plus />
-                        <Trans context="Add new promotion condition">Add condition</Trans>
-                    </Button>
-                </DropdownMenuTrigger>
-                <DropdownMenuContent className="w-96">
-                    {conditions?.map(condition => (
-                        <DropdownMenuItem key={condition.code} onClick={() => onConditionSelected(condition)}>
-                            {condition.description}
-                        </DropdownMenuItem>
-                    ))}
-                </DropdownMenuContent>
-            </DropdownMenu>
-        </div>
+        <ConfigurableOperationMultiSelector
+            value={value}
+            onChange={onChange}
+            queryDocument={promotionConditionsDocument}
+            queryKey="promotionConditions"
+            dataPath="promotionConditions"
+            buttonText="Add condition"
+            dropdownTitle="Available Conditions"
+            showEnhancedDropdown={true}
+        />
     );
 }

+ 10 - 80
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-calculator-selector.tsx

@@ -1,21 +1,7 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { api } from '@/vdb/graphql/api.js';
-import {
-    configurableOperationDefFragment,
-    ConfigurableOperationDefFragment,
-} from '@/vdb/graphql/fragments.js';
+import { ConfigurableOperationSelector } from '@/vdb/components/shared/configurable-operation-selector.js';
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 
 export const shippingCalculatorsDocument = graphql(
     `
@@ -34,70 +20,14 @@ interface ShippingCalculatorSelectorProps {
 }
 
 export function ShippingCalculatorSelector({ value, onChange }: Readonly<ShippingCalculatorSelectorProps>) {
-    const { data: calculatorsData } = useQuery({
-        queryKey: ['shippingCalculators'],
-        queryFn: () => api.query(shippingCalculatorsDocument),
-        staleTime: 1000 * 60 * 60 * 5,
-    });
-
-    const calculators = calculatorsData?.shippingCalculators;
-
-    const onCalculatorSelected = (calculator: ConfigurableOperationDefFragment) => {
-        const calculatorDef = calculators?.find(c => c.code === calculator.code);
-        if (!calculatorDef) {
-            return;
-        }
-        onChange({
-            code: calculator.code,
-            arguments: calculatorDef.args.map(arg => ({
-                name: arg.name,
-                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-            })),
-        });
-    };
-
-    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
-        onChange(newVal);
-    };
-
-    const onOperationRemove = () => {
-        onChange(undefined);
-    };
-
-    const calculatorDef = calculators?.find(c => c.code === value?.code);
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {value && calculatorDef && (
-                <div className="flex flex-col gap-2">
-                    <ConfigurableOperationInput
-                        operationDefinition={calculatorDef}
-                        value={value}
-                        onChange={value => onOperationValueChange(value)}
-                        onRemove={() => onOperationRemove()}
-                    />
-                </div>
-            )}
-            <DropdownMenu>
-                {!value && (
-                    <DropdownMenuTrigger asChild>
-                        <Button variant="outline">
-                            <Plus />
-                            <Trans context="Add new promotion action">Select Shipping Calculator</Trans>
-                        </Button>
-                    </DropdownMenuTrigger>
-                )}
-                <DropdownMenuContent className="w-96">
-                    {calculators?.map(calculator => (
-                        <DropdownMenuItem
-                            key={calculator.code}
-                            onClick={() => onCalculatorSelected(calculator)}
-                        >
-                            {calculator.description}
-                        </DropdownMenuItem>
-                    ))}
-                </DropdownMenuContent>
-            </DropdownMenu>
-        </div>
+        <ConfigurableOperationSelector
+            value={value}
+            onChange={onChange}
+            queryDocument={shippingCalculatorsDocument}
+            queryKey="shippingCalculators"
+            dataPath="shippingCalculators"
+            buttonText="Select Shipping Calculator"
+        />
     );
 }

+ 10 - 79
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx

@@ -1,21 +1,7 @@
-import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { api } from '@/vdb/graphql/api.js';
-import {
-    configurableOperationDefFragment,
-    ConfigurableOperationDefFragment,
-} from '@/vdb/graphql/fragments.js';
+import { ConfigurableOperationSelector } from '@/vdb/components/shared/configurable-operation-selector.js';
+import { configurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Plus } from 'lucide-react';
 
 export const shippingEligibilityCheckersDocument = graphql(
     `
@@ -37,69 +23,14 @@ export function ShippingEligibilityCheckerSelector({
     value,
     onChange,
 }: ShippingEligibilityCheckerSelectorProps) {
-    const { data: checkersData } = useQuery({
-        queryKey: ['shippingEligibilityCheckers'],
-        queryFn: () => api.query(shippingEligibilityCheckersDocument),
-        staleTime: 1000 * 60 * 60 * 5,
-    });
-
-    const checkers = checkersData?.shippingEligibilityCheckers;
-
-    const onCheckerSelected = (checker: ConfigurableOperationDefFragment) => {
-        const checkerDef = checkers?.find(c => c.code === checker.code);
-        if (!checkerDef) {
-            return;
-        }
-        onChange({
-            code: checker.code,
-            arguments: checkerDef.args.map(arg => ({
-                name: arg.name,
-                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
-            })),
-        });
-    };
-
-    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
-        onChange(newVal);
-    };
-
-    const onOperationRemove = () => {
-        onChange(undefined);
-    };
-
-    const checkerDef = checkers?.find(c => c.code === value?.code);
-
     return (
-        <div className="flex flex-col gap-2 mt-4">
-            {value && checkerDef && (
-                <div className="flex flex-col gap-2">
-                    <ConfigurableOperationInput
-                        operationDefinition={checkerDef}
-                        value={value}
-                        onChange={value => onOperationValueChange(value)}
-                        onRemove={() => onOperationRemove()}
-                    />
-                </div>
-            )}
-            <DropdownMenu>
-                {!value && (
-                    <DropdownMenuTrigger asChild>
-                        <Button variant="outline">
-                            <Plus />
-                            <Trans context="Add new promotion action">
-                                Select Shipping Eligibility Checker
-                            </Trans>
-                        </Button>
-                    </DropdownMenuTrigger>
-                )}
-                <DropdownMenuContent className="w-96">
-                    {checkers?.map(checker => (
-                        <DropdownMenuItem key={checker.code} onClick={() => onCheckerSelected(checker)}>
-                            {checker.description}
-                        </DropdownMenuItem>
-                    ))}
-                </DropdownMenuContent>
-            </DropdownMenu>
-        </div>
+        <ConfigurableOperationSelector
+            value={value}
+            onChange={onChange}
+            queryDocument={shippingEligibilityCheckersDocument}
+            queryKey="shippingEligibilityCheckers"
+            dataPath="shippingEligibilityCheckers"
+            buttonText="Select Shipping Eligibility Checker"
+        />
     );
 }

+ 8 - 6
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx

@@ -130,12 +130,14 @@ function ShippingMethodDetailPage() {
                             render={({ field }) => <Input {...field} />}
                         />
                     </DetailFormGrid>
-                    <TranslatableFormFieldWrapper
-                        control={form.control}
-                        name="description"
-                        label={<Trans>Description</Trans>}
-                        render={({ field }) => <Textarea {...field} />}
-                    />
+                    <div className="mb-6">
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="description"
+                            label={<Trans>Description</Trans>}
+                            render={({ field }) => <Textarea {...field} />}
+                        />
+                    </div>
                     <DetailFormGrid>
                         <FormFieldWrapper
                             control={form.control}

+ 52 - 0
packages/dashboard/src/lib/components/data-input/combination-mode-input.tsx

@@ -0,0 +1,52 @@
+import { DataInputComponent } from '@/vdb/framework/component-registry/component-registry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export const CombinationModeInput: DataInputComponent = ({ value, onChange, position, ...props }) => {
+    const booleanValue = value === 'true' || value === true;
+
+    // Only show for items after the first one
+    const selectable = position !== undefined && position > 0;
+
+    const setCombinationModeAnd = () => {
+        onChange(true);
+    };
+
+    const setCombinationModeOr = () => {
+        onChange(false);
+    };
+
+    if (!selectable) {
+        return null;
+    }
+
+    return (
+        <div className="flex items-center justify-center -mt-4 -mb-4">
+            <div className="bg-muted border px-3 py-1.5 rounded-full flex gap-1.5 text-xs shadow-sm">
+                <button
+                    type="button"
+                    className={`px-2 py-0.5 rounded-full transition-colors ${
+                        booleanValue
+                            ? 'bg-primary text-background'
+                            : 'text-muted-foreground hover:bg-muted-foreground/10'
+                    }`}
+                    onClick={setCombinationModeAnd}
+                    {...props}
+                >
+                    <Trans>AND</Trans>
+                </button>
+                <button
+                    type="button"
+                    className={`px-2 py-0.5 rounded-full transition-colors ${
+                        !booleanValue
+                            ? 'bg-primary text-background'
+                            : 'text-muted-foreground hover:bg-muted-foreground/10'
+                    }`}
+                    onClick={setCombinationModeOr}
+                    {...props}
+                >
+                    <Trans>OR</Trans>
+                </button>
+            </div>
+        </div>
+    );
+};

+ 433 - 0
packages/dashboard/src/lib/components/data-input/configurable-operation-list-input.tsx

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

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

@@ -480,6 +480,33 @@ const createEntityConfigs = (i18n: any) => ({
         ),
     }),
 
+    CustomerGroup: createRelationSelectorConfig({
+        ...createBaseEntityConfig('Customer Group', i18n),
+        listQuery: graphql(`
+            query GetCustomerGroupsForRelationSelector($options: CustomerGroupListOptions) {
+                customerGroups(options: $options) {
+                    items {
+                        id
+                        name
+                        customers {
+                            totalItems
+                        }
+                    }
+                    totalItems
+                }
+            }
+        `),
+        label: (item: any) => (
+            <EntityLabel
+                title={item.name}
+                subtitle={`${item.customers?.totalItems || 0} customers`}
+                placeholderLetter="CG"
+                rounded
+                tooltipText={`${item.name} (${item.customers?.totalItems || 0} customers)`}
+            />
+        ),
+    }),
+
     Promotion: createRelationSelectorConfig({
         ...createBaseEntityConfig('Promotion', i18n),
         listQuery: graphql(`

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

@@ -7,6 +7,11 @@ export * from './money-input.js';
 export * from './rich-text-input.js';
 export * from './select-with-options.js';
 
+// Enhanced configurable operation input components
+export * from './configurable-operation-list-input.js';
+export * from './customer-group-selector-input.js';
+export * from './product-selector-input.js';
+
 // Relation selector components
 export * from './relation-input.js';
 export * from './relation-selector.js';

+ 426 - 0
packages/dashboard/src/lib/components/data-input/product-multi-selector.tsx

@@ -0,0 +1,426 @@
+import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Checkbox } from '@/vdb/components/ui/checkbox.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { DataInputComponent } from '@/vdb/framework/component-registry/component-registry.js';
+import { api } from '@/vdb/graphql/api.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import { Plus, X } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+// GraphQL queries
+const searchProductsDocument = graphql(`
+    query SearchProducts($input: SearchInput!) {
+        search(input: $input) {
+            totalItems
+            items {
+                enabled
+                productId
+                productName
+                slug
+                productAsset {
+                    id
+                    preview
+                }
+                productVariantId
+                productVariantName
+                productVariantAsset {
+                    id
+                    preview
+                }
+                sku
+            }
+            facetValues {
+                count
+                facetValue {
+                    id
+                    name
+                    facet {
+                        id
+                        name
+                    }
+                }
+            }
+        }
+    }
+`);
+
+type SearchItem = {
+    enabled: boolean;
+    productId: string;
+    productName: string;
+    slug: string;
+    productAsset?: { id: string; preview: string } | null;
+    productVariantId: string;
+    productVariantName: string;
+    productVariantAsset?: { id: string; preview: string } | null;
+    sku: string;
+};
+
+interface ProductMultiSelectorProps {
+    mode: 'product' | 'variant';
+    initialSelectionIds?: string[];
+    onSelectionChange: (selectedIds: string[]) => void;
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+}
+
+function LoadingState() {
+    return (
+        <div className="text-center text-muted-foreground">
+            <Trans>Loading...</Trans>
+        </div>
+    );
+}
+
+function EmptyState() {
+    return (
+        <div className="text-center text-muted-foreground">
+            <Trans>No items found</Trans>
+        </div>
+    );
+}
+
+function ProductList({
+    items,
+    mode,
+    selectedIds,
+    getItemId,
+    getItemName,
+    toggleSelection,
+}: Readonly<{
+    items: SearchItem[];
+    mode: 'product' | 'variant';
+    selectedIds: Set<string>;
+    getItemId: (item: SearchItem) => string;
+    getItemName: (item: SearchItem) => string;
+    toggleSelection: (item: SearchItem) => void;
+}>) {
+    return (
+        <>
+            {items.map(item => {
+                const itemId = getItemId(item);
+                const isSelected = selectedIds.has(itemId);
+                const asset =
+                    mode === 'product' ? item.productAsset : item.productVariantAsset || item.productAsset;
+
+                return (
+                    <div
+                        key={itemId}
+                        role="checkbox"
+                        tabIndex={0}
+                        aria-checked={isSelected}
+                        className={`border rounded-lg p-3 cursor-pointer transition-colors ${
+                            isSelected
+                                ? 'border-primary bg-primary/5'
+                                : 'border-border hover:border-primary/50'
+                        }`}
+                        onClick={() => toggleSelection(item)}
+                        onKeyDown={e => {
+                            if (e.key === 'Enter' || e.key === ' ') {
+                                e.preventDefault();
+                                toggleSelection(item);
+                            }
+                        }}
+                    >
+                        <div className="flex items-start gap-3">
+                            <div className="flex-shrink-0">
+                                <VendureImage
+                                    asset={asset}
+                                    preset="tiny"
+                                    className="w-16 h-16 rounded object-contain bg-secondary/10"
+                                    fallback={<div className="w-16 h-16 rounded bg-secondary/10" />}
+                                />
+                            </div>
+                            <div className="flex-1 min-w-0">
+                                <div className="font-medium text-sm">{getItemName(item)}</div>
+                                {mode === 'product' ? (
+                                    <div className="text-xs text-muted-foreground">{item.slug}</div>
+                                ) : (
+                                    <div className="text-xs text-muted-foreground">{item.sku}</div>
+                                )}
+                            </div>
+                            <div className="flex-shrink-0">
+                                <Checkbox checked={isSelected} />
+                            </div>
+                        </div>
+                    </div>
+                );
+            })}
+        </>
+    );
+}
+
+function ProductMultiSelectorDialog({
+    mode,
+    initialSelectionIds = [],
+    onSelectionChange,
+    open,
+    onOpenChange,
+}: Readonly<ProductMultiSelectorProps>) {
+    const [searchTerm, setSearchTerm] = useState('');
+    const [selectedItems, setSelectedItems] = useState<SearchItem[]>([]);
+    const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
+
+    // Add debounced search term
+    const debouncedSearchTerm = useDebounce(searchTerm, 300);
+
+    // Search input configuration
+    const searchInput = useMemo(
+        () => ({
+            term: debouncedSearchTerm,
+            groupByProduct: mode === 'product',
+            take: 50,
+            skip: 0,
+        }),
+        [debouncedSearchTerm, mode],
+    );
+
+    // Query search results
+    const { data: searchData, isLoading } = useQuery({
+        queryKey: ['searchProducts', searchInput],
+        queryFn: () => api.query(searchProductsDocument, { input: searchInput }),
+        enabled: open,
+    });
+
+    const items = searchData?.search.items || [];
+
+    // Get the appropriate ID for an item based on mode
+    const getItemId = useCallback(
+        (item: SearchItem): string => {
+            return mode === 'product' ? item.productId : item.productVariantId;
+        },
+        [mode],
+    );
+
+    // Get the appropriate name for an item based on mode
+    const getItemName = useCallback(
+        (item: SearchItem): string => {
+            return mode === 'product' ? item.productName : item.productVariantName;
+        },
+        [mode],
+    );
+
+    // Toggle item selection
+    const toggleSelection = useCallback(
+        (item: SearchItem) => {
+            const itemId = getItemId(item);
+            const newSelectedIds = new Set(selectedIds);
+            const newSelectedItems = [...selectedItems];
+
+            if (selectedIds.has(itemId)) {
+                newSelectedIds.delete(itemId);
+                const index = selectedItems.findIndex(selected => getItemId(selected) === itemId);
+                if (index >= 0) {
+                    newSelectedItems.splice(index, 1);
+                }
+            } else {
+                newSelectedIds.add(itemId);
+                newSelectedItems.push(item);
+            }
+
+            setSelectedIds(newSelectedIds);
+            setSelectedItems(newSelectedItems);
+        },
+        [selectedIds, selectedItems, getItemId],
+    );
+
+    // Clear all selections
+    const clearSelection = useCallback(() => {
+        setSelectedIds(new Set());
+        setSelectedItems([]);
+    }, []);
+
+    // Handle selection confirmation
+    const handleSelect = useCallback(() => {
+        onSelectionChange(Array.from(selectedIds));
+        onOpenChange(false);
+    }, [selectedIds, onSelectionChange, onOpenChange]);
+
+    // Initialize selected items when dialog opens
+    useEffect(() => {
+        if (open) {
+            setSelectedIds(new Set(initialSelectionIds));
+            // We'll update the selectedItems once we have search results that match the IDs
+        }
+    }, [open, initialSelectionIds]);
+
+    // Update selectedItems when we have search results that match our selected IDs
+    useEffect(() => {
+        if (items.length > 0 && selectedIds.size > 0) {
+            const newSelectedItems = items.filter(item => selectedIds.has(getItemId(item)));
+            if (newSelectedItems.length > 0) {
+                setSelectedItems(prevItems => {
+                    const existingIds = new Set(prevItems.map(getItemId));
+                    const uniqueNewItems = newSelectedItems.filter(item => !existingIds.has(getItemId(item)));
+                    return [...prevItems, ...uniqueNewItems];
+                });
+            }
+        }
+    }, [items, selectedIds, getItemId]);
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="max-w-[95vw] md:max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>{mode === 'product' ? 'Select Products' : 'Select Variants'}</Trans>
+                    </DialogTitle>
+                </DialogHeader>
+
+                <div className="flex-1 min-h-0 flex flex-col">
+                    {/* Search Input */}
+                    <div className="flex-shrink-0 mb-4">
+                        <Input
+                            id="search"
+                            placeholder="Search products..."
+                            value={searchTerm}
+                            onChange={e => setSearchTerm(e.target.value)}
+                        />
+                    </div>
+
+                    <div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-6">
+                        {/* Items Grid */}
+                        <div className="lg:col-span-2 overflow-auto flex flex-col">
+                            <div className="space-y-2 p-2">
+                                {isLoading && <LoadingState />}
+                                {!isLoading && items.length === 0 && <EmptyState />}
+                                {!isLoading && items.length > 0 && (
+                                    <ProductList
+                                        items={items}
+                                        mode={mode}
+                                        selectedIds={selectedIds}
+                                        getItemId={getItemId}
+                                        getItemName={getItemName}
+                                        toggleSelection={toggleSelection}
+                                    />
+                                )}
+                            </div>
+                        </div>
+
+                        {/* Selected Items Panel */}
+                        <div className="border rounded-lg p-4 overflow-auto flex flex-col">
+                            <div className="flex items-center justify-between mb-4 flex-shrink-0">
+                                <div className="text-sm font-medium">
+                                    <Trans>Selected Items</Trans>
+                                    <Badge variant="secondary" className="ml-2">
+                                        {selectedItems.length}
+                                    </Badge>
+                                </div>
+                                {selectedItems.length > 0 && (
+                                    <Button variant="outline" size="sm" onClick={clearSelection}>
+                                        <Trans>Clear</Trans>
+                                    </Button>
+                                )}
+                            </div>
+
+                            <div className="space-y-2">
+                                {selectedItems.length === 0 ? (
+                                    <div className="text-center text-muted-foreground text-sm">
+                                        <Trans>No items selected</Trans>
+                                    </div>
+                                ) : (
+                                    selectedItems.map(item => (
+                                        <div
+                                            key={getItemId(item)}
+                                            className="flex items-center justify-between p-2 border rounded"
+                                        >
+                                            <div className="flex-1 min-w-0">
+                                                <div className="text-sm font-medium truncate">
+                                                    {getItemName(item)}
+                                                </div>
+                                                <div className="text-xs text-muted-foreground">
+                                                    {mode === 'product' ? item.slug : item.sku}
+                                                </div>
+                                            </div>
+                                            <Button
+                                                variant="ghost"
+                                                size="icon"
+                                                onClick={() => toggleSelection(item)}
+                                            >
+                                                <X className="h-4 w-4" />
+                                            </Button>
+                                        </div>
+                                    ))
+                                )}
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <DialogFooter className="mt-4">
+                    <Button variant="outline" onClick={() => onOpenChange(false)}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button onClick={handleSelect} disabled={selectedItems.length === 0}>
+                        <Trans>Select {selectedItems.length} Items</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}
+
+export const ProductMultiInput: DataInputComponent = ({ value, onChange, ...props }) => {
+    const [open, setOpen] = useState(false);
+
+    // Parse the configuration from the field definition
+    const mode = (props as any)?.selectionMode === 'variant' ? 'variant' : 'product';
+
+    // Parse the current value (JSON array of IDs)
+    const selectedIds = useMemo(() => {
+        if (!value || typeof value !== 'string') return [];
+        try {
+            return JSON.parse(value);
+        } catch {
+            return [];
+        }
+    }, [value]);
+
+    const handleSelectionChange = useCallback(
+        (newSelectedIds: string[]) => {
+            onChange(JSON.stringify(newSelectedIds));
+        },
+        [onChange],
+    );
+
+    const itemType = mode === 'product' ? 'products' : 'variants';
+    const buttonText =
+        selectedIds.length > 0 ? `Selected ${selectedIds.length} ${itemType}` : `Select ${itemType}`;
+
+    return (
+        <>
+            <div className="space-y-2">
+                <Button variant="outline" onClick={() => setOpen(true)}>
+                    <Plus className="h-4 w-4 mr-2" />
+                    <Trans>{buttonText}</Trans>
+                </Button>
+
+                {selectedIds.length > 0 && (
+                    <div className="text-sm text-muted-foreground">
+                        <Trans>{selectedIds.length} items selected</Trans>
+                    </div>
+                )}
+            </div>
+
+            <ProductMultiSelectorDialog
+                mode={mode}
+                initialSelectionIds={selectedIds}
+                onSelectionChange={handleSelectionChange}
+                open={open}
+                onOpenChange={setOpen}
+            />
+        </>
+    );
+};

+ 365 - 21
packages/dashboard/src/lib/components/shared/configurable-operation-arg-input.tsx

@@ -1,51 +1,395 @@
 import { InputComponent } from '@/vdb/framework/component-registry/dynamic-component.js';
 import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
+import { RelationCustomFieldConfig } from '@vendure/common/lib/generated-types';
 import { ConfigArgType } from '@vendure/core';
+import { AffixedInput } from '../data-input/affixed-input.js';
+import { ConfigurableOperationListInput } from '../data-input/configurable-operation-list-input.js';
+import { DateTimeInput } from '../data-input/datetime-input.js';
+import { DefaultRelationInput } from '../data-input/default-relation-input.js';
 import { FacetValueInput } from '../data-input/facet-value-input.js';
+import { Input } from '../ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
+import { Switch } from '../ui/switch.js';
+import { Textarea } from '../ui/textarea.js';
 
 export interface ConfigurableOperationArgInputProps {
     definition: ConfigurableOperationDefFragment['args'][number];
     readOnly?: boolean;
     value: string;
     onChange: (value: any) => void;
+    position?: number;
 }
 
+/**
+ * Maps Vendure UI component names to their corresponding Dashboard input component IDs
+ */
+const UI_COMPONENT_MAP = {
+    'number-form-input': 'vendure:numberInput',
+    'currency-form-input': 'vendure:currencyInput',
+    'facet-value-form-input': 'facet-value-input',
+    'product-selector-form-input': 'vendure:productSelectorInput',
+    'customer-group-form-input': 'vendure:customerGroupInput',
+    'date-form-input': 'date-input',
+    'textarea-form-input': 'textarea-input',
+    'password-form-input': 'vendure:passwordInput',
+    'json-editor-form-input': 'vendure:jsonEditorInput',
+    'html-editor-form-input': 'vendure:htmlEditorInput',
+    'rich-text-form-input': 'vendure:richTextInput',
+    'boolean-form-input': 'boolean-input',
+    'select-form-input': 'select-input',
+    'text-form-input': 'vendure:textInput',
+    'product-multi-form-input': 'vendure:productMultiInput',
+    'combination-mode-form-input': 'vendure:combinationModeInput',
+    'relation-form-input': 'vendure:relationInput',
+    'struct-form-input': 'vendure:structInput',
+} as const;
+
 export function ConfigurableOperationArgInput({
     definition,
     value,
     onChange,
     readOnly,
-}: ConfigurableOperationArgInputProps) {
-    if ((definition.ui as any)?.component === 'facet-value-form-input') {
-        return <FacetValueInput value={value} onChange={onChange} readOnly={readOnly} />;
+    position,
+}: Readonly<ConfigurableOperationArgInputProps>) {
+    const uiComponent = (definition.ui as any)?.component;
+    const argType = definition.type as ConfigArgType;
+    const isList = definition.list ?? false;
+
+    // Handle specific UI components first
+    if (uiComponent) {
+        switch (uiComponent) {
+            case 'product-selector-form-input': {
+                const entityType =
+                    (definition.ui as any)?.selectionMode === 'variant' ? 'ProductVariant' : 'Product';
+                const isMultiple = (definition.ui as any)?.multiple ?? false;
+                return (
+                    <DefaultRelationInput
+                        fieldDef={
+                            {
+                                entity: entityType,
+                                list: isMultiple,
+                            } as RelationCustomFieldConfig
+                        }
+                        field={{
+                            value,
+                            onChange,
+                            onBlur: () => {},
+                            name: '',
+                            ref: () => {},
+                        }}
+                        disabled={readOnly}
+                    />
+                );
+            }
+            case 'customer-group-form-input': {
+                const isCustomerGroupMultiple = (definition.ui as any)?.multiple ?? false;
+                return (
+                    <DefaultRelationInput
+                        fieldDef={
+                            {
+                                entity: 'CustomerGroup',
+                                list: isCustomerGroupMultiple,
+                            } as RelationCustomFieldConfig
+                        }
+                        field={{
+                            value,
+                            onChange,
+                            onBlur: () => {},
+                            name: '',
+                            ref: () => {},
+                        }}
+                        disabled={readOnly}
+                    />
+                );
+            }
+            case 'facet-value-form-input': {
+                return <FacetValueInput value={value} onChange={onChange} readOnly={readOnly} />;
+            }
+            case 'select-form-input': {
+                return (
+                    <SelectInput
+                        definition={definition}
+                        value={value}
+                        onChange={onChange}
+                        readOnly={readOnly}
+                    />
+                );
+            }
+            case 'textarea-form-input': {
+                return (
+                    <TextareaInput
+                        definition={definition}
+                        value={value}
+                        onChange={onChange}
+                        readOnly={readOnly}
+                    />
+                );
+            }
+            case 'date-form-input': {
+                return <DateTimeInput value={value} onChange={onChange} disabled={readOnly} />;
+            }
+            case 'boolean-form-input': {
+                return <BooleanInput value={value} onChange={onChange} readOnly={readOnly} />;
+            }
+            case 'number-form-input': {
+                return (
+                    <NumberInput
+                        definition={definition}
+                        value={value}
+                        onChange={onChange}
+                        readOnly={readOnly}
+                    />
+                );
+            }
+            case 'currency-form-input': {
+                return (
+                    <CurrencyInput
+                        definition={definition}
+                        value={value}
+                        onChange={onChange}
+                        readOnly={readOnly}
+                    />
+                );
+            }
+            default: {
+                // Try to use the component registry for other UI components
+                const componentId = UI_COMPONENT_MAP[uiComponent as keyof typeof UI_COMPONENT_MAP];
+                if (componentId) {
+                    try {
+                        return (
+                            <InputComponent
+                                id={componentId}
+                                value={value}
+                                onChange={onChange}
+                                readOnly={readOnly}
+                                position={position}
+                                definition={definition}
+                                {...(definition.ui as any)}
+                            />
+                        );
+                    } catch (error) {
+                        console.warn(
+                            `Failed to load UI component ${uiComponent}, falling back to type-based input`,
+                        );
+                    }
+                }
+            }
+        }
+    }
+
+    // Handle list fields with array wrapper
+    if (isList) {
+        return (
+            <ConfigurableOperationListInput
+                definition={definition}
+                value={value}
+                onChange={onChange}
+                readOnly={readOnly}
+            />
+        );
     }
-    switch (definition.type as ConfigArgType) {
+
+    // Fall back to type-based rendering
+    switch (argType) {
         case 'boolean':
+            return <BooleanInput value={value} onChange={onChange} readOnly={readOnly} />;
+
+        case 'int':
+        case 'float':
             return (
-                <InputComponent
-                    id="vendure:checkboxInput"
-                    value={value}
-                    onChange={(value: any) => onChange(value)}
-                    readOnly={readOnly}
-                />
+                <NumberInput definition={definition} value={value} onChange={onChange} readOnly={readOnly} />
             );
-        case 'string':
+
+        case 'datetime':
+            return <DateTimeInput value={value} onChange={onChange} disabled={readOnly} />;
+
+        case 'ID':
+            // ID fields typically need specialized selectors
             return (
-                <InputComponent
-                    id="vendure:textInput"
-                    value={value}
-                    onChange={(value: any) => onChange(value)}
-                    readOnly={readOnly}
+                <Input
+                    type="text"
+                    value={value || ''}
+                    onChange={e => onChange(e.target.value)}
+                    disabled={readOnly}
+                    placeholder="Enter ID..."
+                    className="bg-background"
                 />
             );
+
+        case 'string':
         default:
             return (
-                <InputComponent
-                    id="vendure:textInput"
-                    value={value}
-                    onChange={(value: any) => onChange(value)}
-                    readOnly={readOnly}
+                <Input
+                    type="text"
+                    value={value || ''}
+                    onChange={e => onChange(e.target.value)}
+                    disabled={readOnly}
+                    className="bg-background"
                 />
             );
     }
 }
+
+/**
+ * Boolean input component
+ */
+function BooleanInput({
+    value,
+    onChange,
+    readOnly,
+}: Readonly<{
+    value: string;
+    onChange: (value: string) => void;
+    readOnly?: boolean;
+}>) {
+    const boolValue = value === 'true';
+
+    return (
+        <Switch
+            checked={boolValue}
+            onCheckedChange={checked => onChange(checked.toString())}
+            disabled={readOnly}
+        />
+    );
+}
+
+/**
+ * Number input component with support for UI configuration
+ */
+function NumberInput({
+    definition,
+    value,
+    onChange,
+    readOnly,
+}: Readonly<{
+    definition: ConfigurableOperationDefFragment['args'][number];
+    value: string;
+    onChange: (value: string) => void;
+    readOnly?: boolean;
+}>) {
+    const ui = definition.ui as any;
+    const isFloat = (definition.type as ConfigArgType) === 'float';
+    const min = ui?.min;
+    const max = ui?.max;
+    const step = ui?.step || (isFloat ? 0.01 : 1);
+    const prefix = ui?.prefix;
+    const suffix = ui?.suffix;
+
+    const numericValue = value ? parseFloat(value) : '';
+
+    return (
+        <AffixedInput
+            type="number"
+            value={numericValue}
+            onChange={e => {
+                const val = e.target.valueAsNumber;
+                onChange(isNaN(val) ? '' : val.toString());
+            }}
+            disabled={readOnly}
+            min={min}
+            max={max}
+            step={step}
+            prefix={prefix}
+            suffix={suffix}
+            className="bg-background"
+        />
+    );
+}
+
+/**
+ * Currency input component
+ */
+function CurrencyInput({
+    definition,
+    value,
+    onChange,
+    readOnly,
+}: Readonly<{
+    definition: ConfigurableOperationDefFragment['args'][number];
+    value: string;
+    onChange: (value: string) => void;
+    readOnly?: boolean;
+}>) {
+    const numericValue = value ? parseInt(value, 10) : '';
+
+    return (
+        <AffixedInput
+            type="number"
+            value={numericValue}
+            onChange={e => {
+                const val = e.target.valueAsNumber;
+                onChange(isNaN(val) ? '0' : val.toString());
+            }}
+            disabled={readOnly}
+            min={0}
+            step={1}
+            prefix="$"
+            className="bg-background"
+        />
+    );
+}
+
+/**
+ * Select input component with options
+ */
+function SelectInput({
+    definition,
+    value,
+    onChange,
+    readOnly,
+}: Readonly<{
+    definition: ConfigurableOperationDefFragment['args'][number];
+    value: string;
+    onChange: (value: string) => void;
+    readOnly?: boolean;
+}>) {
+    const ui = definition.ui as any;
+    const options = ui?.options || [];
+
+    return (
+        <Select value={value} onValueChange={onChange} disabled={readOnly}>
+            <SelectTrigger className="bg-background mb-0">
+                <SelectValue placeholder="Select an option..." />
+            </SelectTrigger>
+            <SelectContent>
+                {options.map((option: any) => (
+                    <SelectItem key={option.value} value={option.value}>
+                        {typeof option.label === 'string'
+                            ? option.label
+                            : option.label?.[0]?.value || option.value}
+                    </SelectItem>
+                ))}
+            </SelectContent>
+        </Select>
+    );
+}
+
+/**
+ * Textarea input component
+ */
+function TextareaInput({
+    definition,
+    value,
+    onChange,
+    readOnly,
+}: Readonly<{
+    definition: ConfigurableOperationDefFragment['args'][number];
+    value: string;
+    onChange: (value: string) => void;
+    readOnly?: boolean;
+}>) {
+    const ui = definition.ui as any;
+    const spellcheck = ui?.spellcheck ?? true;
+
+    return (
+        <Textarea
+            value={value || ''}
+            onChange={e => onChange(e.target.value)}
+            disabled={readOnly}
+            spellCheck={spellcheck}
+            placeholder="Enter text..."
+            rows={4}
+            className="bg-background"
+        />
+    );
+}

+ 81 - 41
packages/dashboard/src/lib/components/shared/configurable-operation-input.tsx

@@ -1,8 +1,9 @@
 import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
 import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Trash } from 'lucide-react';
+import { X } from 'lucide-react';
 import { useForm } from 'react-hook-form';
 import { Button } from '../ui/button.js';
+import { Card, CardContent, CardHeader } from '../ui/card.js';
 import { Form, FormControl, FormField, FormItem, FormLabel } from '../ui/form.js';
 import { ConfigurableOperationArgInput } from './configurable-operation-arg-input.js';
 
@@ -26,7 +27,7 @@ export function ConfigurableOperationInput({
     value,
     onChange,
     onRemove,
-}: ConfigurableOperationInputProps) {
+}: Readonly<ConfigurableOperationInputProps>) {
     const form = useForm({
         defaultValues: {
             ...value,
@@ -49,46 +50,85 @@ export function ConfigurableOperationInput({
     };
 
     return (
-        <Form {...form}>
-            <div className="space-y-4">
-                <div className="flex flex-row justify-between">
-                    {!hideDescription && (
-                        <div className="font-medium">
-                            {' '}
-                            {interpolateDescription(operationDefinition, value.arguments)}
+        <div>
+            <Card className="bg-muted/50 shadow-none">
+                <CardHeader className="pb-3">
+                    <div className="flex items-start justify-between">
+                        <div className="flex-1 min-w-0">
+                            {!hideDescription && (
+                                <div className="font-medium text-sm text-foreground leading-relaxed">
+                                    {interpolateDescription(operationDefinition, value.arguments)}
+                                </div>
+                            )}
+
+                            {operationDefinition.code && (
+                                <div className="text-xs text-muted-foreground mt-1 font-mono">
+                                    {operationDefinition.code}
+                                </div>
+                            )}
                         </div>
-                    )}
-                    {removable !== false && (
-                        <Button variant="outline" size="icon" onClick={onRemove}>
-                            <Trash />
-                        </Button>
-                    )}
-                </div>
-                <div className="grid grid-cols-2 gap-4">
-                    {operationDefinition.args.map(arg => {
-                        const argValue = value.arguments.find(a => a.name === arg.name)?.value || '';
-                        return (
-                            <FormField
-                                key={arg.name}
-                                name={`args.${arg.name}`}
-                                render={() => (
-                                    <FormItem>
-                                        <FormLabel>{arg.label || arg.name}</FormLabel>
-                                        <FormControl>
-                                            <ConfigurableOperationArgInput
-                                                definition={arg}
-                                                value={argValue}
-                                                onChange={value => handleInputChange(arg.name, value)}
-                                            />
-                                        </FormControl>
-                                    </FormItem>
-                                )}
-                            />
-                        );
-                    })}
-                </div>
-            </div>
-        </Form>
+
+                        {removable !== false && (
+                            <Button
+                                variant="ghost"
+                                size="sm"
+                                onClick={onRemove}
+                                className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
+                                disabled={readonly}
+                            >
+                                <X className="h-3.5 w-3.5" />
+                            </Button>
+                        )}
+                    </div>
+                </CardHeader>
+
+                {operationDefinition.args && operationDefinition.args.length > 0 && (
+                    <CardContent className="pt-0">
+                        <Form {...form}>
+                            <div className="space-y-4">
+                                <div
+                                    className={`grid gap-4 ${operationDefinition.args.length === 1 ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}`}
+                                >
+                                    {operationDefinition.args
+                                        .filter(
+                                            arg =>
+                                                arg.ui?.component !== 'combination-mode-form-input',
+                                        )
+                                        .map(arg => {
+                                            const argValue =
+                                                value.arguments.find(a => a.name === arg.name)?.value || '';
+                                            return (
+                                                <FormField
+                                                    key={arg.name}
+                                                    name={`args.${arg.name}`}
+                                                    render={() => (
+                                                        <FormItem className="space-y-2">
+                                                            <FormLabel className="text-sm font-medium text-foreground">
+                                                                {arg.label || arg.name}
+                                                            </FormLabel>
+                                                            <FormControl>
+                                                                <ConfigurableOperationArgInput
+                                                                    definition={arg}
+                                                                    value={argValue}
+                                                                    onChange={value =>
+                                                                        handleInputChange(arg.name, value)
+                                                                    }
+                                                                    readOnly={readonly}
+                                                                    position={position}
+                                                                />
+                                                            </FormControl>
+                                                        </FormItem>
+                                                    )}
+                                                />
+                                            );
+                                        })}
+                                </div>
+                            </div>
+                        </Form>
+                    </CardContent>
+                )}
+            </Card>
+        </div>
     );
 }
 

+ 260 - 0
packages/dashboard/src/lib/components/shared/configurable-operation-multi-selector.tsx

@@ -0,0 +1,260 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu.js';
+import { InputComponent } from '@/vdb/framework/component-registry/dynamic-component.js';
+import { api } from '@/vdb/graphql/api.js';
+import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { DefinedInitialDataOptions, useQuery, UseQueryOptions } from '@tanstack/react-query';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { Plus } from 'lucide-react';
+import { ConfigurableOperationInput } from './configurable-operation-input.js';
+
+/**
+ * Props interface for ConfigurableOperationMultiSelector component
+ */
+export interface ConfigurableOperationMultiSelectorProps {
+    /** Array of currently selected configurable operations */
+    value: ConfigurableOperationInputType[];
+    /** Callback function called when the selection changes */
+    onChange: (value: ConfigurableOperationInputType[]) => void;
+    /** GraphQL document for querying available operations (alternative to queryOptions) */
+    queryDocument?: any;
+    /** Pre-configured query options for more complex queries (alternative to queryDocument) */
+    queryOptions?: UseQueryOptions<any> | DefinedInitialDataOptions<any>;
+    /** Unique key for the query cache */
+    queryKey: string;
+    /** Dot-separated path to extract operations from query result (e.g., "promotionConditions") */
+    dataPath: string;
+    /** Text to display on the add button */
+    buttonText: string;
+    /** Title to show at the top of the dropdown menu (only when showEnhancedDropdown is true) */
+    dropdownTitle?: string;
+    /** Text to display when no operations are available (defaults to "No options found") */
+    emptyText?: string;
+    /**
+     * Controls the dropdown display style:
+     * - true: Enhanced dropdown with larger width (w-80), section title, operation descriptions + codes
+     * - false: Simple dropdown with standard width (w-96), just operation descriptions
+     *
+     * Enhanced style is used by promotion conditions/actions for better UX with complex operations.
+     * Simple style is used by collection filters for a cleaner, more compact appearance.
+     */
+    showEnhancedDropdown?: boolean;
+}
+
+type QueryData = {
+    [key: string]: ConfigurableOperationDefFragment[];
+};
+
+/**
+ * ConfigurableOperationMultiSelector - A reusable component for selecting multiple configurable operations
+ *
+ * This component provides a standardized interface for selecting multiple configurable operations such as:
+ * - Collection filters
+ * - Promotion conditions
+ * - Promotion actions
+ *
+ * Features:
+ * - Displays all selected operations with their configuration forms
+ * - Provides a dropdown to add new operations from available options
+ * - Handles individual operation updates and removals
+ * - Supports position-based combination mode for operations
+ * - Flexible query patterns (direct document or pre-configured options)
+ * - Two dropdown styles: enhanced (with operation codes) or simple
+ *
+ * @example
+ * ```tsx
+ * // Enhanced dropdown style (promotions)
+ * <ConfigurableOperationMultiSelector
+ *   value={conditions}
+ *   onChange={setConditions}
+ *   queryDocument={promotionConditionsDocument}
+ *   queryKey="promotionConditions"
+ *   dataPath="promotionConditions"
+ *   buttonText="Add condition"
+ *   dropdownTitle="Available Conditions"
+ *   showEnhancedDropdown={true}
+ * />
+ *
+ * // Simple dropdown style (collections)
+ * <ConfigurableOperationMultiSelector
+ *   value={filters}
+ *   onChange={setFilters}
+ *   queryOptions={getCollectionFiltersQueryOptions}
+ *   queryKey="getCollectionFilters"
+ *   dataPath="collectionFilters"
+ *   buttonText="Add collection filter"
+ *   showEnhancedDropdown={false}
+ * />
+ * ```
+ */
+export function ConfigurableOperationMultiSelector({
+    value,
+    onChange,
+    queryDocument,
+    queryOptions,
+    queryKey,
+    dataPath,
+    buttonText,
+    dropdownTitle,
+    emptyText = 'No options found',
+    showEnhancedDropdown = true,
+}: Readonly<ConfigurableOperationMultiSelectorProps>) {
+    const { data } = useQuery<QueryData>(
+        queryOptions || {
+            queryKey: [queryKey],
+            queryFn: () => api.query(queryDocument),
+            staleTime: 1000 * 60 * 60 * 5,
+        },
+    );
+
+    // Extract operations from the data using the provided path
+    const operations = dataPath.split('.').reduce<any>((obj, key) => {
+        if (obj && typeof obj === 'object') {
+            return obj[key];
+        }
+        return undefined;
+    }, data) as ConfigurableOperationDefFragment[] | undefined;
+
+    const onOperationSelected = (operation: ConfigurableOperationDefFragment) => {
+        const operationDef = operations?.find(
+            (op: ConfigurableOperationDefFragment) => op.code === operation.code,
+        );
+        if (!operationDef) {
+            return;
+        }
+        onChange([
+            ...value,
+            {
+                code: operation.code,
+                arguments: operationDef.args.map(arg => ({
+                    name: arg.name,
+                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+                })),
+            },
+        ]);
+    };
+
+    const onOperationValueChange = (
+        operation: ConfigurableOperationInputType,
+        newVal: ConfigurableOperationInputType,
+    ) => {
+        onChange(value.map(op => (op.code === operation.code ? newVal : op)));
+    };
+
+    const onOperationRemove = (index: number) => {
+        onChange(value.filter((_, i) => i !== index));
+    };
+
+    const onCombinationModeChange = (index: number, newValue: boolean | string) => {
+        const updatedValue = [...value];
+        const operation = updatedValue[index];
+        if (operation) {
+            const updatedOperation = {
+                ...operation,
+                arguments: operation.arguments.map(arg =>
+                    arg.name === 'combineWithAnd' ? { ...arg, value: newValue.toString() } : arg,
+                ),
+            };
+            updatedValue[index] = updatedOperation;
+            onChange(updatedValue);
+        }
+    };
+
+    const hasOperations = value && value.length > 0;
+
+    return (
+        <div className="space-y-4">
+            {hasOperations && (
+                <div className="space-y-0">
+                    {value.map((operation, index) => {
+                        const operationDef = operations?.find(
+                            (op: ConfigurableOperationDefFragment) => op.code === operation.code,
+                        );
+                        if (!operationDef) {
+                            return null;
+                        }
+                        const hasCombinationMode = operation.arguments.find(arg => arg.name === 'combineWithAnd');
+                        return (
+                            <div key={index + operation.code}>
+                                {index > 0 && hasCombinationMode ? (
+                                    <div className="my-2">
+                                        <InputComponent
+                                            id="vendure:combinationModeInput"
+                                            value={
+                                                operation.arguments.find(arg => arg.name === 'combineWithAnd')
+                                                    ?.value ?? 'true'
+                                            }
+                                            onChange={(newValue: boolean | string) =>
+                                                onCombinationModeChange(index, newValue)
+                                            }
+                                            position={index}
+                                        />
+                                    </div>
+                                ) : (
+                                    <div className="h-4" />
+                                )}
+                                <ConfigurableOperationInput
+                                    operationDefinition={operationDef}
+                                    value={operation}
+                                    onChange={value => onOperationValueChange(operation, value)}
+                                    onRemove={() => onOperationRemove(index)}
+                                    position={index}
+                                />
+                            </div>
+                        );
+                    })}
+                </div>
+            )}
+
+            <div className={hasOperations ? 'pt-2' : ''}>
+                <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="outline" className="w-full sm:w-auto">
+                            <Plus className="h-4 w-4" />
+                            <Trans>{buttonText}</Trans>
+                        </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent className={showEnhancedDropdown ? 'w-80' : 'w-96'} align="start">
+                        {showEnhancedDropdown && dropdownTitle && (
+                            <div className="px-2 py-1.5 text-sm font-medium text-muted-foreground">
+                                {dropdownTitle}
+                            </div>
+                        )}
+                        {operations?.length ? (
+                            operations.map((operation: ConfigurableOperationDefFragment) => (
+                                <DropdownMenuItem
+                                    key={operation.code}
+                                    onClick={() => onOperationSelected(operation)}
+                                    className={
+                                        showEnhancedDropdown
+                                            ? 'flex flex-col items-start py-3 cursor-pointer'
+                                            : undefined
+                                    }
+                                >
+                                    {showEnhancedDropdown ? (
+                                        <>
+                                            <div className="font-medium text-sm">{operation.description}</div>
+                                            <div className="text-xs text-muted-foreground font-mono mt-1">
+                                                {operation.code}
+                                            </div>
+                                        </>
+                                    ) : (
+                                        operation.description
+                                    )}
+                                </DropdownMenuItem>
+                            ))
+                        ) : (
+                            <DropdownMenuItem>{emptyText}</DropdownMenuItem>
+                        )}
+                    </DropdownMenuContent>
+                </DropdownMenu>
+            </div>
+        </div>
+    );
+}

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

@@ -0,0 +1,156 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu.js';
+import { api } from '@/vdb/graphql/api.js';
+import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { useQuery } from '@tanstack/react-query';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { Plus } from 'lucide-react';
+import { ConfigurableOperationInput } from './configurable-operation-input.js';
+
+/**
+ * Props interface for ConfigurableOperationSelector component
+ */
+export interface ConfigurableOperationSelectorProps {
+    /** Current selected configurable operation value */
+    value: ConfigurableOperationInputType | undefined;
+    /** Callback function called when the selection changes */
+    onChange: (value: ConfigurableOperationInputType | undefined) => void;
+    /** GraphQL document for querying available operations */
+    queryDocument: any;
+    /** Unique key for the query cache */
+    queryKey: string;
+    /** Dot-separated path to extract operations from query result (e.g., "paymentMethodHandlers") */
+    dataPath: string;
+    /** Text to display on the selection button */
+    buttonText: string;
+    /** Text to display when no operations are available (defaults to "No options found") */
+    emptyText?: string;
+}
+
+type QueryData = {
+    [key: string]: {
+        [key: string]: ConfigurableOperationDefFragment[];
+    };
+};
+
+/**
+ * ConfigurableOperationSelector - A reusable component for selecting a single configurable operation
+ * 
+ * This component provides a standardized interface for selecting configurable operations such as:
+ * - Payment method handlers
+ * - Payment eligibility checkers  
+ * - Shipping calculators
+ * - Shipping eligibility checkers
+ * 
+ * Features:
+ * - Displays the selected operation with its configuration form
+ * - Provides a dropdown to select from available operations
+ * - Handles operation selection with default argument values
+ * - Supports removal of selected operations
+ * 
+ * @example
+ * ```tsx
+ * <ConfigurableOperationSelector
+ *   value={selectedHandler}
+ *   onChange={setSelectedHandler}
+ *   queryDocument={paymentHandlersDocument}
+ *   queryKey="paymentMethodHandlers"
+ *   dataPath="paymentMethodHandlers"
+ *   buttonText="Select Payment Handler"
+ * />
+ * ```
+ */
+export function ConfigurableOperationSelector({
+    value,
+    onChange,
+    queryDocument,
+    queryKey,
+    dataPath,
+    buttonText,
+    emptyText = 'No options found',
+}: Readonly<ConfigurableOperationSelectorProps>) {
+    const { data } = useQuery<QueryData>({
+        queryKey: [queryKey],
+        queryFn: () => api.query(queryDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    // Extract operations from the data using the provided path
+    const operations = dataPath.split('.').reduce<any>((obj, key) => {
+        if (obj && typeof obj === 'object') {
+            return obj[key];
+        }
+        return undefined;
+    }, data) as ConfigurableOperationDefFragment[] | undefined;
+
+    const onOperationSelected = (operation: ConfigurableOperationDefFragment) => {
+        const operationDef = operations?.find(
+            (op: ConfigurableOperationDefFragment) => op.code === operation.code,
+        );
+        if (!operationDef) {
+            return;
+        }
+        onChange({
+            code: operation.code,
+            arguments: operationDef.args.map(arg => ({
+                name: arg.name,
+                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+            })),
+        });
+    };
+
+    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
+        onChange(newVal);
+    };
+
+    const onOperationRemove = () => {
+        onChange(undefined);
+    };
+
+    const operationDef = operations?.find((op: ConfigurableOperationDefFragment) => op.code === value?.code);
+
+    return (
+        <div className="flex flex-col gap-2 mt-4">
+            {value && operationDef && (
+                <div className="flex flex-col gap-2">
+                    <ConfigurableOperationInput
+                        operationDefinition={operationDef}
+                        value={value}
+                        onChange={value => onOperationValueChange(value)}
+                        onRemove={() => onOperationRemove()}
+                    />
+                </div>
+            )}
+            <DropdownMenu>
+                {!value?.code && (
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="outline" className="w-fit">
+                            <Plus />
+                            <Trans>{buttonText}</Trans>
+                        </Button>
+                    </DropdownMenuTrigger>
+                )}
+                <DropdownMenuContent className="w-96">
+                    {operations?.length ? (
+                        operations.map((operation: ConfigurableOperationDefFragment) => (
+                            <DropdownMenuItem
+                                key={operation.code}
+                                onClick={() => onOperationSelected(operation)}
+                            >
+                                {operation.description}
+                            </DropdownMenuItem>
+                        ))
+                    ) : (
+                        <DropdownMenuItem>{emptyText}</DropdownMenuItem>
+                    )}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 5 - 1
packages/dashboard/src/lib/framework/extension-api/input-component-extensions.tsx

@@ -1,6 +1,8 @@
+import { CombinationModeInput } from '@/vdb/components/data-input/combination-mode-input.js';
 import { DateTimeInput } from '@/vdb/components/data-input/datetime-input.js';
 import { FacetValueInput } from '@/vdb/components/data-input/facet-value-input.js';
 import { MoneyInput } from '@/vdb/components/data-input/money-input.js';
+import { ProductMultiInput } from '@/vdb/components/data-input/product-multi-selector.js';
 import { Checkbox } from '@/vdb/components/ui/checkbox.js';
 import { Input } from '@/vdb/components/ui/input.js';
 import { DataInputComponent } from '../component-registry/component-registry.js';
@@ -31,6 +33,8 @@ inputComponents.set('vendure:numberInput', NumberInput);
 inputComponents.set('vendure:dateTimeInput', DateTimeInput);
 inputComponents.set('vendure:checkboxInput', CheckboxInput);
 inputComponents.set('vendure:facetValueInput', FacetValueInput);
+inputComponents.set('vendure:combinationModeInput', CombinationModeInput);
+inputComponents.set('vendure:productMultiInput', ProductMultiInput);
 
 export function getInputComponent(id: string): DataInputComponent | undefined {
     return globalRegistry.get('inputComponents').get(id);
@@ -54,7 +58,7 @@ export function addInputComponent({
     pageId: string;
     blockId: string;
     field: string;
-    component: React.ComponentType<{ value: any; onChange: (value: any) => void; [key: string]: any }>;
+    component: DataInputComponent;
 }) {
     const inputComponents = globalRegistry.get('inputComponents');