Sfoglia il codice sorgente

feat(admin-ui): Implement list types for ConfigurableOperationDef args

Relates to #414
Michael Bromley 5 anni fa
parent
commit
4c7467b676
24 ha cambiato i file con 388 aggiunte e 141 eliminazioni
  1. 2 1
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts
  2. 1 0
      packages/admin-ui/src/lib/core/src/common/component-registry-types.ts
  3. 6 2
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  4. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.html
  5. 7 3
      packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts
  6. 20 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.html
  7. 27 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.scss
  8. 166 22
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  9. 1 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.ts
  10. 8 3
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.ts
  11. 0 25
      packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.html
  12. 11 10
      packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.ts
  13. 13 3
      packages/admin-ui/src/lib/settings/src/components/shipping-method-detail/shipping-method-detail.component.ts
  14. 4 4
      packages/core/e2e/__snapshots__/promotion.e2e-spec.ts.snap
  15. 86 24
      packages/core/e2e/custom-fields.e2e-spec.ts
  16. 1 1
      packages/core/e2e/promotion.e2e-spec.ts
  17. 9 9
      packages/core/e2e/shipping-method.e2e-spec.ts
  18. 8 22
      packages/core/src/common/configurable-operation.ts
  19. 8 2
      packages/core/src/config/collection/default-collection-filters.ts
  20. 3 2
      packages/core/src/config/promotion/actions/facet-values-discount-action.ts
  21. 2 2
      packages/core/src/config/promotion/actions/order-percentage-discount-action.ts
  22. 1 1
      packages/core/src/config/promotion/conditions/has-facet-values-condition.ts
  23. 2 2
      packages/core/src/config/shipping-method/default-shipping-calculator.ts
  24. 1 1
      packages/core/src/config/shipping-method/default-shipping-eligibility-checker.ts

+ 2 - 1
packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts

@@ -20,6 +20,7 @@ import {
     createUpdatedTranslatable,
     CustomFieldConfig,
     DataService,
+    encodeConfigArgValue,
     getConfigArgValue,
     LanguageCode,
     ModalService,
@@ -288,7 +289,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
                 code: o.code,
                 arguments: Object.values(formValueOperations[i].args).map((value: any, j) => ({
                     name: o.args[j].name,
-                    value: value.toString(),
+                    value: encodeConfigArgValue(value),
                 })),
             };
         });

+ 1 - 0
packages/admin-ui/src/lib/core/src/common/component-registry-types.ts

@@ -1,6 +1,7 @@
 import { FormControl } from '@angular/forms';
 
 export interface FormInputComponent {
+    isListInput?: boolean;
     readonly: boolean;
     formControl: FormControl;
     config: InputComponentConfig;

+ 6 - 2
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts

@@ -16,14 +16,18 @@ export function getConfigArgValue(value: any) {
     }
 }
 
+export function encodeConfigArgValue(value: any): string {
+    return Array.isArray(value) ? JSON.stringify(value) : value.toString();
+}
+
 /**
  * Returns a default value based on the type of the config arg.
  */
 export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
-    return arg.list ? [] : getSingleValue(arg.type as ConfigArgType);
+    return arg.list ? [] : getDefaultConfigArgSingleValue(arg.type as ConfigArgType);
 }
 
-function getSingleValue(type: ConfigArgType): any {
+export function getDefaultConfigArgSingleValue(type: ConfigArgType): any {
     switch (type) {
         case 'boolean':
             return 'false';

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.html

@@ -2,7 +2,7 @@
     <div class="card-block">{{ interpolateDescription() }}</div>
     <div class="card-block" *ngIf="operation.args?.length">
         <form [formGroup]="form" *ngIf="operation" class="operation-inputs">
-            <div *ngFor="let arg of operation.args" class="arg-row">
+            <div *ngFor="let arg of operation.args; trackBy: trackByName" class="arg-row">
                 <ng-container *ngIf="form.get(arg.name)">
                     <label>{{ arg.name | sentenceCase }}</label>
                     <vdr-dynamic-form-input

+ 7 - 3
packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts

@@ -108,9 +108,12 @@ export class ConfigurableInputComponent implements OnChanges, OnDestroy, Control
         }
     }
 
+    trackByName(index: number, arg: ConfigArg): string {
+        return arg.name;
+    }
+
     getArgDef(arg: ConfigArg): ConfigArgDefinition | undefined {
-        const argDef = this.operationDefinition?.args.find(a => a.name === arg.name);
-        return argDef;
+        return this.operationDefinition?.args.find(a => a.name === arg.name);
     }
 
     private createForm() {
@@ -128,7 +131,8 @@ export class ConfigurableInputComponent implements OnChanges, OnDestroy, Control
                 if (value === undefined) {
                     value = getDefaultConfigArgValue(arg);
                 }
-                this.form.addControl(arg.name, new FormControl(value, Validators.required));
+                const validators = arg.list ? undefined : Validators.required;
+                this.form.addControl(arg.name, new FormControl(value, validators));
             }
         }
 

+ 20 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.html

@@ -1 +1,20 @@
-<ng-container #outlet></ng-container>
+<ng-container *ngIf="!renderAsList; else list">
+    <ng-container #single></ng-container>
+</ng-container>
+<ng-template #list>
+    <div class="list-container" cdkDropList (cdkDropListDropped)="moveListItem($event)">
+        <div class="list-item-row" *ngFor="let item of listItems; trackBy: trackById" cdkDrag [cdkDragData]="item">
+            <ng-container #listItem></ng-container>
+            <button class="btn btn-link btn-sm btn-warning" (click)="removeListItem(item)">
+                <clr-icon shape="times"></clr-icon>
+            </button>
+            <div class="flex-spacer"></div>
+            <div class="drag-handle" cdkDragHandle *vdrIfPermissions="'UpdateCatalog'">
+                <clr-icon shape="drag-handle" size="24"></clr-icon>
+            </div>
+        </div>
+        <button class="btn btn-secondary btn-sm" (click)="addListItem()">
+            <clr-icon shape="plus"></clr-icon>
+        </button>
+    </div>
+</ng-template>

+ 27 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.scss

@@ -0,0 +1,27 @@
+@import "variables";
+
+.list-item-row {
+    display: flex;
+    align-items: center;
+}
+.drag-placeholder {
+    min-height: 120px;
+    background-color: $color-grey-300;
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}
+
+.cdk-drag-preview {
+    box-sizing: border-box;
+    border-radius: 4px;
+    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
+    0 8px 10px 1px rgba(0, 0, 0, 0.14),
+    0 3px 14px 2px rgba(0, 0, 0, 0.12);
+}
+
+.cdk-drag-placeholder {
+    opacity: 0;
+}
+
+.cdk-drag-animating {
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}

+ 166 - 22
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -1,26 +1,45 @@
+import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
 import {
     AfterViewInit,
     ChangeDetectionStrategy,
     ChangeDetectorRef,
     Component,
+    ComponentFactory,
     ComponentFactoryResolver,
     ComponentRef,
-    forwardRef,
+    Injector,
     Input,
     OnChanges,
+    OnDestroy,
+    OnInit,
+    QueryList,
     SimpleChanges,
+    Type,
     ViewChild,
+    ViewChildren,
     ViewContainerRef,
 } from '@angular/core';
-import { ControlValueAccessor, FormControl, FormControlName, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { ControlValueAccessor, FormArray, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { getConfigArgValue, getDefaultConfigArgSingleValue } from '@vendure/admin-ui/core';
 import { ConfigArgType } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
 
 import { FormInputComponent, InputComponentConfig } from '../../../common/component-registry-types';
-import { ConfigArg, ConfigArgDefinition } from '../../../common/generated-types';
+import { ConfigArgDefinition } from '../../../common/generated-types';
 import { ComponentRegistryService } from '../../../providers/component-registry/component-registry.service';
 
+type InputListItem = {
+    id: number;
+    componentRef?: ComponentRef<FormInputComponent>;
+    control: FormControl;
+};
+
+/**
+ * A host component which delegates to an instance or list of FormInputComponent components.
+ */
 @Component({
     selector: 'vdr-dynamic-form-input',
     templateUrl: './dynamic-form-input.component.html',
@@ -34,47 +53,168 @@ import { ComponentRegistryService } from '../../../providers/component-registry/
         },
     ],
 })
-export class DynamicFormInputComponent implements OnChanges, AfterViewInit, ControlValueAccessor {
+export class DynamicFormInputComponent
+    implements OnInit, OnChanges, AfterViewInit, OnDestroy, ControlValueAccessor {
     @Input() def: ConfigArgDefinition;
     @Input() readonly: boolean;
     @Input() control: FormControl;
-    @ViewChild('outlet', { read: ViewContainerRef }) viewContainer: ViewContainerRef;
+    @ViewChild('single', { read: ViewContainerRef }) singleViewContainer: ViewContainerRef;
+    @ViewChildren('listItem', { read: ViewContainerRef }) listItemContainers: QueryList<ViewContainerRef>;
+    renderAsList = false;
+    listItems: InputListItem[] = [];
+    private singleComponentRef: ComponentRef<FormInputComponent>;
+    private listId = 1;
+    private listFormArray = new FormArray([]);
+    private componentType: Type<FormInputComponent>;
     private onChange: (val: any) => void;
     private onTouch: () => void;
-    private componentRef: ComponentRef<FormInputComponent>;
+    private destroy$ = new Subject();
 
     constructor(
         private componentRegistryService: ComponentRegistryService,
         private componentFactoryResolver: ComponentFactoryResolver,
         private changeDetectorRef: ChangeDetectorRef,
+        private injector: Injector,
     ) {}
 
-    ngAfterViewInit() {
+    ngOnInit() {
         const componentType = this.componentRegistryService.getInputComponent(
             this.getInputComponentConfig(this.def).component,
         );
         if (componentType) {
-            const factory = this.componentFactoryResolver.resolveComponentFactory(componentType);
-            const componentRef = this.viewContainer.createComponent(factory);
-            const { instance } = componentRef;
-            this.componentRef = componentRef;
-            instance.config = simpleDeepClone(this.def.ui);
-            instance.formControl = this.control;
-            instance.readonly = this.readonly;
+            this.componentType = componentType;
+        }
+    }
+
+    ngAfterViewInit() {
+        if (this.componentType) {
+            const factory = this.componentFactoryResolver.resolveComponentFactory(this.componentType);
+
+            // create a temp instance to check the value of `isListInput`
+            const cmpRef = factory.create(this.injector);
+            const isListInputComponent = cmpRef.instance.isListInput ?? false;
+            cmpRef.destroy();
+
+            if (this.def.list === false && isListInputComponent) {
+                throw new Error(
+                    `The ${this.componentType.name} component is a list input, but the definition for ${this.def.name} does not expect a list`,
+                );
+            }
+            this.renderAsList = this.def.list && !isListInputComponent;
+            if (!this.renderAsList) {
+                this.singleComponentRef = this.renderInputComponent(
+                    factory,
+                    this.singleViewContainer,
+                    this.control,
+                );
+            } else {
+                const arrayValue = Array.isArray(this.control.value)
+                    ? this.control.value
+                    : !!this.control.value
+                    ? [this.control.value]
+                    : [];
+                this.listItems = arrayValue.map(
+                    value =>
+                        ({
+                            id: this.listId++,
+                            control: new FormControl(getConfigArgValue(value)),
+                        } as InputListItem),
+                );
+                let firstRenderHasOccurred = false;
+                const renderListInputs = (viewContainerRefs: QueryList<ViewContainerRef>) => {
+                    viewContainerRefs.forEach((ref, i) => {
+                        const listItem = this.listItems[i];
+                        if (!this.listFormArray.controls.includes(listItem.control)) {
+                            this.listFormArray.push(listItem.control);
+                            listItem.componentRef = this.renderInputComponent(factory, ref, listItem.control);
+                        }
+                    });
+                    firstRenderHasOccurred = true;
+                };
+
+                this.listItemContainers.changes
+                    .pipe(takeUntil(this.destroy$))
+                    .subscribe((refs: QueryList<ViewContainerRef>) => {
+                        renderListInputs(refs);
+                    });
+
+                this.listFormArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
+                    if (firstRenderHasOccurred) {
+                        this.control.markAsTouched();
+                        this.control.markAsDirty();
+                        this.onChange(val);
+                    }
+                    this.control.patchValue(val, { emitEvent: false });
+                });
+            }
         }
         setTimeout(() => this.changeDetectorRef.markForCheck());
     }
 
     ngOnChanges(changes: SimpleChanges) {
-        if (this.componentRef) {
-            if ('config' in changes) {
-                this.componentRef.instance.config = this.def.ui;
+        if (this.listItems) {
+            for (const item of this.listItems) {
+                if (item.componentRef) {
+                    this.updateBindings(changes, item.componentRef);
+                }
             }
-            if ('readonly' in changes) {
-                this.componentRef.instance.readonly = this.readonly;
-            }
-            this.componentRef.injector.get(ChangeDetectorRef).markForCheck();
         }
+        if (this.singleComponentRef) {
+            this.updateBindings(changes, this.singleComponentRef);
+        }
+    }
+
+    ngOnDestroy() {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    private updateBindings(changes: SimpleChanges, componentRef: ComponentRef<FormInputComponent>) {
+        if ('def' in changes) {
+            componentRef.instance.config = this.def.ui;
+        }
+        if ('readonly' in changes) {
+            componentRef.instance.readonly = this.readonly;
+        }
+        componentRef.injector.get(ChangeDetectorRef).markForCheck();
+    }
+
+    trackById(index: number, item: { id: number }) {
+        return item.id;
+    }
+
+    addListItem() {
+        this.listItems.push({
+            id: this.listId++,
+            control: new FormControl(getDefaultConfigArgSingleValue(this.def.type as ConfigArgType)),
+        });
+    }
+
+    moveListItem(event: CdkDragDrop<InputListItem>) {
+        moveItemInArray(this.listItems, event.previousIndex, event.currentIndex);
+        this.listFormArray.removeAt(event.previousIndex);
+        this.listFormArray.insert(event.currentIndex, event.item.data.control);
+    }
+
+    removeListItem(item: InputListItem) {
+        const index = this.listItems.findIndex(i => i === item);
+        item.componentRef?.destroy();
+        this.listFormArray.removeAt(index);
+        this.listItems = this.listItems.filter(i => i !== item);
+    }
+
+    private renderInputComponent(
+        factory: ComponentFactory<FormInputComponent>,
+        viewContainerRef: ViewContainerRef,
+        formControl: FormControl,
+    ) {
+        const componentRef = viewContainerRef.createComponent(factory);
+        const { instance } = componentRef;
+        instance.config = simpleDeepClone(this.def.ui);
+        instance.formControl = formControl;
+        instance.readonly = this.readonly;
+        componentRef.injector.get(ChangeDetectorRef).markForCheck();
+        return componentRef;
     }
 
     registerOnChange(fn: any): void {
@@ -96,7 +236,11 @@ export class DynamicFormInputComponent implements OnChanges, AfterViewInit, Cont
         const type = argDef?.type as ConfigArgType;
         switch (type) {
             case 'string':
-                return { component: 'text-form-input' };
+                if (argDef.ui?.options) {
+                    return { component: 'select-form-input' };
+                } else {
+                    return { component: 'text-form-input' };
+                }
             case 'int':
             case 'float':
                 return { component: 'number-form-input' };

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.ts

@@ -13,6 +13,7 @@ import { FormInputComponent, InputComponentConfig } from '../../../common/compon
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class FacetValueFormInputComponent implements FormInputComponent, OnInit {
+    readonly isListInput = true;
     readonly: boolean;
     formControl: FormControl;
     facets$: Observable<FacetWithValues.Fragment[]>;

+ 8 - 3
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.ts

@@ -9,6 +9,8 @@ import {
     ConfigurableOperationInput,
     CreatePromotionInput,
     DataService,
+    encodeConfigArgValue,
+    getConfigArgValue,
     getDefaultConfigArgValue,
     LanguageCode,
     NotificationService,
@@ -219,7 +221,7 @@ export class PromotionDetailComponent extends BaseDetailComponent<Promotion.Frag
                 code: o.code,
                 arguments: Object.values<any>(formValueOperations[i].args).map((value, j) => ({
                     name: o.args[j].name,
-                    value: value.toString(),
+                    value: encodeConfigArgValue(value),
                 })),
             };
         });
@@ -237,7 +239,7 @@ export class PromotionDetailComponent extends BaseDetailComponent<Promotion.Frag
                 (output, arg) => ({
                     ...output,
                     [arg.name]:
-                        arg.value != null ? arg.value : this.getDefaultArgValue(key, operation, arg.name),
+                        getConfigArgValue(arg.value) ?? this.getDefaultArgValue(key, operation, arg.name),
                 }),
                 {},
             );
@@ -247,7 +249,10 @@ export class PromotionDetailComponent extends BaseDetailComponent<Promotion.Frag
                     args: argsHash,
                 }),
             );
-            collection.push(operation);
+            collection.push({
+                code: operation.code,
+                args: operation.args.map(a => ({ name: a.name, value: getConfigArgValue(a.value) })),
+            });
         }
     }
 

+ 0 - 25
packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.html

@@ -55,31 +55,6 @@
             <div class="clr-col">
                 <label>{{ 'settings.payment-method-config-options' | translate }}</label>
                 <section class="form-block" *ngFor="let arg of paymentMethod.configArgs; index as i">
-                    <!--<vdr-form-field [label]="arg.name" [for]="arg.name" *ngIf="getType(paymentMethod, arg.name) === 'string'">
-                        <input
-                            [id]="arg.name"
-                            type="text"
-                            [readonly]="!('UpdateSettings' | hasPermission)"
-                            [formControlName]="arg.name"
-                        />
-                    </vdr-form-field>
-                    <vdr-form-field [label]="arg.name" [for]="arg.name" *ngIf="getType(paymentMethod, arg.name) === 'int'">
-                        <input
-                            [id]="arg.name"
-                            type="number"
-                            [readonly]="!('UpdateSettings' | hasPermission)"
-                            [formControlName]="arg.name"
-                        />
-                    </vdr-form-field>
-                    <vdr-form-field [label]="arg.name" [for]="arg.name" *ngIf="getType(paymentMethod, arg.name) === 'boolean'">
-                        <input
-                            type="checkbox"
-                            [id]="arg.name"
-                            [vdrDisabled]="!('UpdateSettings' | hasPermission)"
-                            [formControlName]="arg.name"
-                            clrCheckbox
-                        />
-                    </vdr-form-field>-->
                     <vdr-form-field
                         [label]="arg.name"
                         [for]="arg.name"

+ 11 - 10
packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.ts

@@ -4,14 +4,15 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     BaseDetailComponent,
+    ConfigArgDefinition,
     DataService,
+    encodeConfigArgValue,
     getConfigArgValue,
     NotificationService,
     PaymentMethod,
     ServerConfigService,
     UpdatePaymentMethodInput,
 } from '@vendure/admin-ui/core';
-import { ConfigArgType } from '@vendure/common/lib/shared-types';
 import { mergeMap, take } from 'rxjs/operators';
 
 @Component({
@@ -49,8 +50,8 @@ export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMet
         this.destroy();
     }
 
-    getType(paymentMethod: PaymentMethod.Fragment, argName: string): ConfigArgType | undefined {
-        return paymentMethod.definition.args.find(a => a.name === argName)?.type as ConfigArgType;
+    getArgDef(paymentMethod: PaymentMethod.Fragment, argName: string): ConfigArgDefinition | undefined {
+        return paymentMethod.definition.args.find(a => a.name === argName);
     }
 
     configArgsIsPopulated(): boolean {
@@ -73,7 +74,7 @@ export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMet
                         enabled: formValue.enabled,
                         configArgs: Object.entries<any>(formValue.configArgs).map(([name, value], i) => ({
                             name,
-                            value: value.toString(),
+                            value: encodeConfigArgValue(value),
                         })),
                     };
                     return this.dataService.settings.updatePaymentMethod(input);
@@ -103,14 +104,14 @@ export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMet
         const configArgsGroup = this.detailForm.get('configArgs') as FormGroup;
         if (configArgsGroup) {
             for (const arg of paymentMethod.configArgs) {
-                const control = configArgsGroup.get(arg.name);
+                let control = configArgsGroup.get(arg.name);
+                const def = this.getArgDef(paymentMethod, arg.name);
+                const value = def?.list === true && arg.value === '' ? [] : getConfigArgValue(arg.value);
                 if (control) {
-                    control.patchValue(getConfigArgValue(arg.value));
+                    control.patchValue(value);
                 } else {
-                    configArgsGroup.addControl(
-                        arg.name,
-                        this.formBuilder.control(getConfigArgValue(arg.value)),
-                    );
+                    control = this.formBuilder.control(value);
+                    configArgsGroup.addControl(arg.name, control);
                 }
             }
         }

+ 13 - 3
packages/admin-ui/src/lib/settings/src/components/shipping-method-detail/shipping-method-detail.component.ts

@@ -9,7 +9,9 @@ import {
     ConfigurableOperationInput,
     CreateShippingMethodInput,
     DataService,
+    encodeConfigArgValue,
     GetActiveChannel,
+    getConfigArgValue,
     getDefaultConfigArgValue,
     NotificationService,
     ServerConfigService,
@@ -264,7 +266,9 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
             code: operation.code,
             arguments: Object.values<any>(formValueOperations.args || {}).map((value, j) => ({
                 name: operation.args[j].name,
-                value: value.hasOwnProperty('value') ? (value as any).value : value.toString(),
+                value: value.hasOwnProperty('value')
+                    ? encodeConfigArgValue((value as any).value)
+                    : encodeConfigArgValue(value),
             })),
         };
     }
@@ -276,7 +280,13 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
             checker: shippingMethod.checker || {},
             calculator: shippingMethod.calculator || {},
         });
-        this.selectedChecker = shippingMethod.checker;
-        this.selectedCalculator = shippingMethod.calculator;
+        this.selectedChecker = {
+            code: shippingMethod.checker.code,
+            args: shippingMethod.checker.args.map(a => ({ ...a, value: getConfigArgValue(a.value) })),
+        };
+        this.selectedCalculator = {
+            code: shippingMethod.calculator.code,
+            args: shippingMethod.calculator.args.map(a => ({ ...a, value: getConfigArgValue(a.value) })),
+        };
     }
 }

+ 4 - 4
packages/core/e2e/__snapshots__/promotion.e2e-spec.ts.snap

@@ -5,9 +5,9 @@ Array [
   Object {
     "args": Array [
       Object {
-        "config": null,
         "name": "facetValueIds",
-        "type": "facetValueIds",
+        "type": "ID",
+        "ui": null,
       },
     ],
     "code": "promo_action",
@@ -21,9 +21,9 @@ Array [
   Object {
     "args": Array [
       Object {
-        "config": null,
         "name": "arg",
         "type": "int",
+        "ui": null,
       },
     ],
     "code": "promo_condition",
@@ -32,9 +32,9 @@ Array [
   Object {
     "args": Array [
       Object {
-        "config": null,
         "name": "arg",
         "type": "int",
+        "ui": null,
       },
     ],
     "code": "promo_condition2",

+ 86 - 24
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -5,7 +5,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { fixPostgresTimezone } from './utils/fix-pg-timezone';
@@ -97,6 +97,32 @@ const customConfig = mergeConfig(testConfig, {
                 type: 'string',
                 internal: true,
             },
+            {
+                name: 'stringList',
+                type: 'string',
+                list: true,
+            },
+            {
+                name: 'localeStringList',
+                type: 'localeString',
+                list: true,
+            },
+            {
+                name: 'stringListWithDefault',
+                type: 'string',
+                list: true,
+                defaultValue: ['cat'],
+            },
+            {
+                name: 'intListWithValidation',
+                type: 'int',
+                list: true,
+                validate: value => {
+                    if (!value.includes(42)) {
+                        return `Must include the number 42!`;
+                    }
+                },
+            },
         ],
         Facet: [
             {
@@ -140,6 +166,7 @@ describe('Custom fields', () => {
                                 ... on CustomField {
                                     name
                                     type
+                                    list
                                 }
                             }
                         }
@@ -150,26 +177,30 @@ describe('Custom fields', () => {
 
         expect(globalSettings.serverConfig.customFieldConfig).toEqual({
             Product: [
-                { name: 'nullable', type: 'string' },
-                { name: 'notNullable', type: 'string' },
-                { name: 'stringWithDefault', type: 'string' },
-                { name: 'localeStringWithDefault', type: 'localeString' },
-                { name: 'intWithDefault', type: 'int' },
-                { name: 'floatWithDefault', type: 'float' },
-                { name: 'booleanWithDefault', type: 'boolean' },
-                { name: 'dateTimeWithDefault', type: 'datetime' },
-                { name: 'validateString', type: 'string' },
-                { name: 'validateLocaleString', type: 'localeString' },
-                { name: 'validateInt', type: 'int' },
-                { name: 'validateFloat', type: 'float' },
-                { name: 'validateDateTime', type: 'datetime' },
-                { name: 'validateFn1', type: 'string' },
-                { name: 'validateFn2', type: 'string' },
-                { name: 'stringWithOptions', type: 'string' },
-                { name: 'nonPublic', type: 'string' },
-                { name: 'public', type: 'string' },
-                { name: 'longString', type: 'string' },
-                { name: 'readonlyString', type: 'string' },
+                { name: 'nullable', type: 'string', list: false },
+                { name: 'notNullable', type: 'string', list: false },
+                { name: 'stringWithDefault', type: 'string', list: false },
+                { name: 'localeStringWithDefault', type: 'localeString', list: false },
+                { name: 'intWithDefault', type: 'int', list: false },
+                { name: 'floatWithDefault', type: 'float', list: false },
+                { name: 'booleanWithDefault', type: 'boolean', list: false },
+                { name: 'dateTimeWithDefault', type: 'datetime', list: false },
+                { name: 'validateString', type: 'string', list: false },
+                { name: 'validateLocaleString', type: 'localeString', list: false },
+                { name: 'validateInt', type: 'int', list: false },
+                { name: 'validateFloat', type: 'float', list: false },
+                { name: 'validateDateTime', type: 'datetime', list: false },
+                { name: 'validateFn1', type: 'string', list: false },
+                { name: 'validateFn2', type: 'string', list: false },
+                { name: 'stringWithOptions', type: 'string', list: false },
+                { name: 'nonPublic', type: 'string', list: false },
+                { name: 'public', type: 'string', list: false },
+                { name: 'longString', type: 'string', list: false },
+                { name: 'readonlyString', type: 'string', list: false },
+                { name: 'stringList', type: 'string', list: true },
+                { name: 'localeStringList', type: 'localeString', list: true },
+                { name: 'stringListWithDefault', type: 'string', list: true },
+                { name: 'intListWithValidation', type: 'int', list: true },
                 // The internal type should not be exposed at all
                 // { name: 'internalString', type: 'string' },
             ],
@@ -233,6 +264,7 @@ describe('Custom fields', () => {
                         floatWithDefault
                         booleanWithDefault
                         dateTimeWithDefault
+                        stringListWithDefault
                     }
                 }
             }
@@ -248,6 +280,7 @@ describe('Custom fields', () => {
                 floatWithDefault: 5.5,
                 booleanWithDefault: true,
                 dateTimeWithDefault: '2019-04-30T12:59:16.415Z',
+                stringListWithDefault: ['cat'],
             },
         });
     });
@@ -266,7 +299,7 @@ describe('Custom fields', () => {
     );
 
     it(
-        'thows on attempt to update readonly field',
+        'throws on attempt to update readonly field',
         assertThrowsWithMessage(async () => {
             await adminClient.query(gql`
                 mutation {
@@ -279,7 +312,7 @@ describe('Custom fields', () => {
     );
 
     it(
-        'thows on attempt to update readonly field when no other custom fields defined',
+        'throws on attempt to update readonly field when no other custom fields defined',
         assertThrowsWithMessage(async () => {
             await adminClient.query(gql`
                 mutation {
@@ -292,7 +325,7 @@ describe('Custom fields', () => {
     );
 
     it(
-        'thows on attempt to create readonly field',
+        'throws on attempt to create readonly field',
         assertThrowsWithMessage(async () => {
             await adminClient.query(gql`
                 mutation {
@@ -462,6 +495,35 @@ describe('Custom fields', () => {
                 `);
             }, `The value ['invalid'] is not valid`),
         );
+
+        it(
+            'invalid list field',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(
+                            input: { id: "T_1", customFields: { intListWithValidation: [1, 2, 3] } }
+                        ) {
+                            id
+                        }
+                    }
+                `);
+            }, `Must include the number 42!`),
+        );
+
+        it('valid list field', async () => {
+            const { updateProduct } = await adminClient.query(gql`
+                mutation {
+                    updateProduct(input: { id: "T_1", customFields: { intListWithValidation: [1, 42, 3] } }) {
+                        id
+                        customFields {
+                            intListWithValidation
+                        }
+                    }
+                }
+            `);
+            expect(updateProduct.customFields.intListWithValidation).toEqual([1, 42, 3]);
+        });
     });
 
     describe('public access', () => {

+ 1 - 1
packages/core/e2e/promotion.e2e-spec.ts

@@ -301,7 +301,7 @@ export const CONFIGURABLE_DEF_FRAGMENT = gql`
         args {
             name
             type
-            config
+            ui
         }
         code
         description

+ 9 - 9
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -74,13 +74,13 @@ describe('ShippingMethod resolver', () => {
             {
                 args: [
                     {
-                        config: {
-                            inputType: 'money',
-                        },
                         description: 'Order is eligible only if its total is greater or equal to this value',
                         label: 'Minimum order value',
                         name: 'orderMinimum',
                         type: 'int',
+                        ui: {
+                            component: 'currency-form-input',
+                        },
                     },
                 ],
                 code: 'default-shipping-eligibility-checker',
@@ -96,8 +96,8 @@ describe('ShippingMethod resolver', () => {
             {
                 args: [
                     {
-                        config: {
-                            inputType: 'money',
+                        ui: {
+                            component: 'currency-form-input',
                         },
                         description: null,
                         label: 'Shipping price',
@@ -105,8 +105,8 @@ describe('ShippingMethod resolver', () => {
                         type: 'int',
                     },
                     {
-                        config: {
-                            inputType: 'percentage',
+                        ui: {
+                            suffix: '%',
                         },
                         description: null,
                         label: 'Tax rate',
@@ -365,7 +365,7 @@ const GET_ELIGIBILITY_CHECKERS = gql`
                 type
                 description
                 label
-                config
+                ui
             }
         }
     }
@@ -381,7 +381,7 @@ const GET_CALCULATORS = gql`
                 type
                 description
                 label
-                config
+                ui
             }
         }
     }

+ 8 - 22
packages/core/src/common/configurable-operation.ts

@@ -22,6 +22,8 @@ import { InjectableStrategy } from './types/injectable-strategy';
 /**
  * @description
  * An array of string values in a given {@link LanguageCode}, used to define human-readable string values.
+ * The `ui` property can be used in conjunction with the Vendure Admin UI to specify a custom form input
+ * component.
  *
  * @example
  * ```TypeScript
@@ -42,6 +44,10 @@ export interface ConfigArgCommonDef<T extends ConfigArgType> {
     list?: boolean;
     label?: LocalizedStringArray;
     description?: LocalizedStringArray;
+    ui?: {
+        component?: string;
+        [prop: string]: any;
+    };
 }
 
 export type ConfigArgListDef<
@@ -76,7 +82,7 @@ export type ConfigArgDef<T extends ConfigArgType> = T extends 'string'
  * {
  *   operator: {
  *     type: 'string',
- *     config: {
+ *     ui: {
  *       options: [
  *         { value: 'startsWith' },
  *         { value: 'endsWith' },
@@ -204,7 +210,7 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
                         name,
                         type: arg.type,
                         list: arg.list ?? false,
-                        config: localizeConfig(arg, ctx.languageCode),
+                        ui: arg.ui,
                         label: arg.label && localizeString(arg.label, ctx.languageCode),
                         description: arg.description && localizeString(arg.description, ctx.languageCode),
                     } as Required<ConfigArgDefinition>),
@@ -236,26 +242,6 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
     }
 }
 
-function localizeConfig(
-    arg: StringArgConfig | IntArgConfig | WithArgConfig<undefined>,
-    languageCode: LanguageCode,
-): any {
-    const { config } = arg;
-    if (!config) {
-        return config;
-    }
-    const clone = simpleDeepClone(config);
-    const options: Maybe<StringFieldOption[]> = (clone as any).options;
-    if (options) {
-        for (const option of options) {
-            if (option.label) {
-                (option as any).label = localizeString(option.label, languageCode);
-            }
-        }
-    }
-    return clone;
-}
-
 function localizeString(stringArray: LocalizedStringArray, languageCode: LanguageCode): string {
     let match = stringArray.find(x => x.languageCode === languageCode);
     if (!match) {

+ 8 - 2
packages/core/src/config/collection/default-collection-filters.ts

@@ -10,7 +10,13 @@ import { CollectionFilter } from './collection-filter';
  */
 export const facetValueCollectionFilter = new CollectionFilter({
     args: {
-        facetValueIds: { type: 'ID', list: true },
+        facetValueIds: {
+            type: 'ID',
+            list: true,
+            ui: {
+                component: 'facet-value-form-input',
+            },
+        },
         containsAny: { type: 'boolean' },
     },
     code: 'facet-value-filter',
@@ -67,7 +73,7 @@ export const variantNameCollectionFilter = new CollectionFilter({
     args: {
         operator: {
             type: 'string',
-            config: {
+            ui: {
                 options: [
                     { value: 'startsWith' },
                     { value: 'endsWith' },

+ 3 - 2
packages/core/src/config/promotion/actions/facet-values-discount-action.ts

@@ -10,13 +10,14 @@ export const discountOnItemWithFacets = new PromotionItemAction({
     args: {
         discount: {
             type: 'int',
-            config: {
-                inputType: 'percentage',
+            ui: {
+                suffix: '%',
             },
         },
         facets: {
             type: 'ID',
             list: true,
+            ui: { component: 'facet-value-form-input' },
         },
     },
     init(injector) {

+ 2 - 2
packages/core/src/config/promotion/actions/order-percentage-discount-action.ts

@@ -7,8 +7,8 @@ export const orderPercentageDiscount = new PromotionOrderAction({
     args: {
         discount: {
             type: 'int',
-            config: {
-                inputType: 'percentage',
+            ui: {
+                suffix: '%',
             },
         },
     },

+ 1 - 1
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -13,7 +13,7 @@ export const hasFacetValues = new PromotionCondition({
     ],
     args: {
         minimum: { type: 'int' },
-        facets: { type: 'ID', list: true },
+        facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
     },
     init(injector) {
         facetValueChecker = new FacetValueChecker(injector.getConnection());

+ 2 - 2
packages/core/src/config/shipping-method/default-shipping-calculator.ts

@@ -8,12 +8,12 @@ export const defaultShippingCalculator = new ShippingCalculator({
     args: {
         rate: {
             type: 'int',
-            config: { inputType: 'money' },
+            ui: { component: 'currency-form-input' },
             label: [{ languageCode: LanguageCode.en, value: 'Shipping price' }],
         },
         taxRate: {
             type: 'int',
-            config: { inputType: 'percentage' },
+            ui: { suffix: '%' },
             label: [{ languageCode: LanguageCode.en, value: 'Tax rate' }],
         },
     },

+ 1 - 1
packages/core/src/config/shipping-method/default-shipping-eligibility-checker.ts

@@ -8,7 +8,7 @@ export const defaultShippingEligibilityChecker = new ShippingEligibilityChecker(
     args: {
         orderMinimum: {
             type: 'int',
-            config: { inputType: 'money' },
+            ui: { component: 'currency-form-input' },
             label: [{ languageCode: LanguageCode.en, value: 'Minimum order value' }],
             description: [
                 {