Browse Source

Merge branch 'minor' into major

Michael Bromley 3 years ago
parent
commit
240b96265a
49 changed files with 1010 additions and 137 deletions
  1. 28 28
      packages/admin-ui/i18n-coverage.json
  2. 2 2
      packages/admin-ui/scripts/extract-translations.js
  3. 0 2
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  4. 2 1
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.html
  5. 12 1
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts
  6. 1 2
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts
  7. 0 1
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  8. 72 0
      packages/admin-ui/src/lib/core/src/common/utilities/selection-manager.ts
  9. 4 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  10. 14 10
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html
  11. 4 7
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.scss
  12. 21 36
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts
  13. 5 2
      packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.scss
  14. 15 2
      packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts
  15. 92 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.html
  16. 76 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.scss
  17. 167 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.ts
  18. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.html
  19. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.scss
  20. 3 1
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.ts
  21. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/select-toggle/select-toggle.component.html
  22. 14 16
      packages/admin-ui/src/lib/core/src/shared/components/select-toggle/select-toggle.component.scss
  23. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/select-toggle/select-toggle.component.ts
  24. 21 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component.html
  25. 4 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component.scss
  26. 51 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component.ts
  27. 5 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component.html
  28. 0 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component.scss
  29. 54 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component.ts
  30. 4 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  31. 11 8
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts
  32. 8 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  33. 2 0
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html
  34. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  35. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  36. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  37. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  38. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  39. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  40. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  41. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  42. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  43. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  44. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  45. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  46. 8 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  47. 9 1
      packages/common/src/shared-types.ts
  48. 88 1
      packages/core/e2e/collection.e2e-spec.ts
  49. 114 15
      packages/core/src/config/catalog/default-collection-filters.ts

+ 28 - 28
packages/admin-ui/i18n-coverage.json

@@ -1,71 +1,71 @@
 {
-  "generatedOn": "2022-04-21T20:04:59.938Z",
-  "lastCommit": "dbf3f1412a2da6f6dd53930557326f022eae62e7",
+  "generatedOn": "2022-04-27T11:59:25.441Z",
+  "lastCommit": "47c9b0ead7057d2060590fc43a82d5c8876beacd",
   "translationStatus": {
     "cs": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 591,
-      "percentage": 92
+      "percentage": 91
     },
     "de": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 570,
-      "percentage": 89
+      "percentage": 88
     },
     "en": {
-      "tokenCount": 641,
-      "translatedCount": 640,
+      "tokenCount": 649,
+      "translatedCount": 646,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 623,
-      "percentage": 97
+      "percentage": 96
     },
     "fr": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 613,
-      "percentage": 96
+      "percentage": 94
     },
     "it": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 621,
-      "percentage": 97
+      "percentage": 96
     },
     "pl": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 405,
-      "percentage": 63
+      "percentage": 62
     },
     "pt_BR": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 589,
-      "percentage": 92
+      "percentage": 91
     },
     "pt_PT": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 634,
-      "percentage": 99
+      "percentage": 98
     },
     "ru": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 620,
-      "percentage": 97
+      "percentage": 96
     },
     "uk": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 620,
-      "percentage": 97
+      "percentage": 96
     },
     "zh_Hans": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 557,
-      "percentage": 87
+      "percentage": 86
     },
     "zh_Hant": {
-      "tokenCount": 641,
+      "tokenCount": 649,
       "translatedCount": 385,
-      "percentage": 60
+      "percentage": 59
     }
   }
 }

+ 2 - 2
packages/admin-ui/scripts/extract-translations.js

@@ -25,6 +25,7 @@ async function extractTranslations() {
         lastCommit: await getLastGitCommitHash(),
         translationStatus: {},
     };
+    console.log(`locales`, locales);
     for (const locale of locales) {
         const outputPath = path.join(
             path.relative(path.join(__dirname, '..'), MESSAGES_DIR),
@@ -37,7 +38,6 @@ async function extractTranslations() {
             const { tokenCount, translatedCount, percentage } = getStatsForLocale(locale);
             console.log(`${locale}: ${translatedCount} of ${tokenCount} tokens translated (${percentage}%)`);
             console.log('');
-
             report.translationStatus[locale] = { tokenCount, translatedCount, percentage };
         } catch (e) {
             console.log(e);
@@ -54,7 +54,7 @@ function runExtraction(locale) {
     const args = getNgxTranslateExtractCommand(locale);
     return new Promise((resolve, reject) => {
         try {
-            const child = spawn(`yarnpkg`, args, { stdio: ['pipe', 'pipe', process.stderr] });
+            const child = spawn(`yarnpkg`, args, { stdio: ['inherit', 'inherit', 'inherit'] });
             child.on('close', x => {
                 resolve();
             });

+ 0 - 2
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -21,7 +21,6 @@ import { OptionValueInputComponent } from './components/option-value-input/optio
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductOptionsEditorComponent } from './components/product-options-editor/product-options-editor.component';
-import { ProductSearchInputComponent } from './components/product-search-input/product-search-input.component';
 import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
 import { ProductVariantsListComponent } from './components/product-variants-list/product-variants-list.component';
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
@@ -45,7 +44,6 @@ const CATALOG_COMPONENTS = [
     CollectionTreeNodeComponent,
     CollectionContentsComponent,
     ProductVariantsTableComponent,
-    ProductSearchInputComponent,
     OptionValueInputComponent,
     UpdateProductOptionDialogComponent,
     ProductVariantsEditorComponent,

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

@@ -103,7 +103,7 @@
             ></vdr-assets>
         </div>
     </div>
-    <div class="clr-row">
+    <div class="clr-row" formArrayName="filters">
         <div class="clr-col">
             <label>{{ 'catalog.filters' | translate }}</label>
             <vdr-form-field [label]="'catalog.filter-inheritance' | translate" for="inheritFilters">
@@ -129,6 +129,7 @@
                 <ng-container *ngFor="let filter of filters; index as i; trackBy:trackByFn">
                     <vdr-configurable-input
                         (remove)="removeFilter(i)"
+                        [position]="i"
                         [operation]="filter"
                         [operationDefinition]="getFilterDefinition(filter)"
                         [formControlName]="i"

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

@@ -96,7 +96,18 @@ export class CollectionDetailComponent
         this.updatedFilters$ = filtersFormArray.statusChanges.pipe(
             debounceTime(200),
             filter(() => filtersFormArray.touched),
-            map(status => this.mapOperationsToInputs(this.filters, filtersFormArray.value)),
+            map(status =>
+                this.mapOperationsToInputs(this.filters, filtersFormArray.value).filter(filter => {
+                    // ensure all the arguments have valid values. E.g. a newly-added
+                    // filter will not yet have valid values
+                    for (const arg of filter.arguments) {
+                        if (arg.value === '') {
+                            return false;
+                        }
+                    }
+                    return true;
+                }),
+            ),
         );
         this.parentId$ = this.route.paramMap.pipe(
             map(pm => pm.get('parentId') || undefined),

+ 1 - 2
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts

@@ -11,6 +11,7 @@ import {
     LogicalOperator,
     ModalService,
     NotificationService,
+    ProductSearchInputComponent,
     SearchInput,
     SearchProductsQuery,
     SearchProductsQueryVariables,
@@ -19,8 +20,6 @@ import {
 import { EMPTY, Observable } from 'rxjs';
 import { delay, map, switchMap, take, takeUntil, tap, withLatestFrom } from 'rxjs/operators';
 
-import { ProductSearchInputComponent } from '../product-search-input/product-search-input.component';
-
 @Component({
     selector: 'vdr-products-list',
     templateUrl: './product-list.component.html',

+ 0 - 1
packages/admin-ui/src/lib/catalog/src/public_api.ts

@@ -23,7 +23,6 @@ export * from './components/product-detail/product-detail.component';
 export * from './components/product-detail/product-detail.types';
 export * from './components/product-list/product-list.component';
 export * from './components/product-options-editor/product-options-editor.component';
-export * from './components/product-search-input/product-search-input.component';
 export * from './components/product-variants-editor/product-variants-editor.component';
 export * from './components/product-variants-list/product-variants-list.component';
 export * from './components/product-variants-table/product-variants-table.component';

+ 72 - 0
packages/admin-ui/src/lib/core/src/common/utilities/selection-manager.ts

@@ -0,0 +1,72 @@
+export interface SelectionManagerOptions<T> {
+    multiSelect: boolean;
+    itemsAreEqual: (a: T, b: T) => boolean;
+    additiveMode: boolean;
+}
+
+/**
+ * @description
+ * A helper class used to manage selection of list items. Supports multiple selection via
+ * cmd/ctrl/shift key.
+ */
+export class SelectionManager<T> {
+    constructor(private options: SelectionManagerOptions<T>) {}
+
+    get selection(): T[] {
+        return this._selection;
+    }
+
+    private _selection: T[] = [];
+    private items: T[] = [];
+
+    setMultiSelect(isMultiSelect: boolean) {
+        this.options.multiSelect = isMultiSelect;
+    }
+
+    setCurrentItems(items: T[]) {
+        this.items = items;
+    }
+
+    toggleSelection(item: T, event?: MouseEvent) {
+        const { multiSelect, itemsAreEqual, additiveMode } = this.options;
+        const index = this._selection.findIndex(a => itemsAreEqual(a, item));
+        if (multiSelect && event?.shiftKey && 1 <= this._selection.length) {
+            const lastSelection = this._selection[this._selection.length - 1];
+            const lastSelectionIndex = this.items.findIndex(a => itemsAreEqual(a, lastSelection));
+            const currentIndex = this.items.findIndex(a => itemsAreEqual(a, item));
+            const start = currentIndex < lastSelectionIndex ? currentIndex : lastSelectionIndex;
+            const end = currentIndex > lastSelectionIndex ? currentIndex + 1 : lastSelectionIndex;
+            this._selection.push(
+                ...this.items.slice(start, end).filter(a => !this._selection.find(s => itemsAreEqual(a, s))),
+            );
+        } else if (index === -1) {
+            if (multiSelect && (event?.ctrlKey || event?.shiftKey || additiveMode)) {
+                this._selection.push(item);
+            } else {
+                this._selection = [item];
+            }
+        } else {
+            if (multiSelect && event?.ctrlKey) {
+                this._selection.splice(index, 1);
+            } else if (1 < this._selection.length && !additiveMode) {
+                this._selection = [item];
+            } else {
+                this._selection.splice(index, 1);
+            }
+        }
+        // Make the selection mutable
+        this._selection = this._selection.map(x => ({ ...x }));
+    }
+
+    selectMultiple(items: T[]) {
+        this._selection = items;
+    }
+
+    isSelected(item: T): boolean {
+        return !!this._selection.find(a => this.options.itemsAreEqual(a, item));
+    }
+
+    lastSelected(): T {
+        return this._selection[this._selection.length - 1];
+    }
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -18,6 +18,7 @@ export * from './common/utilities/find-translation';
 export * from './common/utilities/flatten-facet-values';
 export * from './common/utilities/get-default-ui-language';
 export * from './common/utilities/interpolate-description';
+export * from './common/utilities/selection-manager';
 export * from './common/utilities/string-to-color';
 export * from './common/version';
 export * from './components/app-shell/app-shell.component';
@@ -147,6 +148,8 @@ export * from './shared/components/modal-dialog/modal-dialog.component';
 export * from './shared/components/object-tree/object-tree.component';
 export * from './shared/components/order-state-label/order-state-label.component';
 export * from './shared/components/pagination-controls/pagination-controls.component';
+export * from './shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component';
+export * from './shared/components/product-search-input/product-search-input.component';
 export * from './shared/components/product-variant-selector/product-variant-selector.component';
 export * from './shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component';
 export * from './shared/components/rich-text-editor/link-dialog/link-dialog.component';
@@ -184,6 +187,7 @@ export * from './shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-inpu
 export * from './shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component';
 export * from './shared/dynamic-form-inputs/number-form-input/number-form-input.component';
 export * from './shared/dynamic-form-inputs/password-form-input/password-form-input.component';
+export * from './shared/dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component';
 export * from './shared/dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component';
 export * from './shared/dynamic-form-inputs/register-dynamic-input-components';
 export * from './shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component';

+ 14 - 10
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html

@@ -6,7 +6,11 @@
         [class.selected]="isSelected(asset)"
     >
         <div class="card-img">
-            <div class="selected-checkbox"><clr-icon shape="check-circle" size="32"></clr-icon></div>
+            <vdr-select-toggle
+                [selected]="isSelected(asset)"
+                [disabled]="true"
+                [hiddenWhenOff]="true"
+            ></vdr-select-toggle>
             <img [src]="asset | assetPreview: 'thumb'" />
         </div>
         <div class="detail">
@@ -22,21 +26,21 @@
 <div class="info-bar">
     <div class="card">
         <div class="card-img">
-            <div class="placeholder" *ngIf="selection.length === 0">
+            <div class="placeholder" *ngIf="selectionManager.selection.length === 0">
                 <clr-icon shape="image" size="128"></clr-icon>
                 <div>{{ 'catalog.no-selection' | translate }}</div>
             </div>
             <img
                 class="preview"
-                *ngIf="selection.length >= 1"
+                *ngIf="selectionManager.selection.length >= 1"
                 [src]="lastSelected().preview + '?preset=medium'"
             />
         </div>
-        <div class="card-block details" *ngIf="selection.length >= 1">
+        <div class="card-block details" *ngIf="selectionManager.selection.length >= 1">
             <div class="name">{{ lastSelected().name }}</div>
             <div>{{ 'asset.original-asset-size' | translate }}: {{ lastSelected().fileSize | filesize }}</div>
 
-            <ng-container *ngIf="selection.length === 1">
+            <ng-container *ngIf="selectionManager.selection.length === 1">
                 <vdr-chip *ngFor="let tag of lastSelected().tags" [colorFrom]="tag.value"
                     ><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag.value }}</vdr-chip
                 >
@@ -55,17 +59,17 @@
                 </div>
             </ng-container>
             <div *ngIf="canDelete">
-                <button (click)="deleteAssets.emit(selection)" class="btn btn-link">
+                <button (click)="deleteAssets.emit(selectionManager.selection)" class="btn btn-link">
                     <clr-icon shape="trash" class="is-danger"></clr-icon> {{ 'common.delete' | translate }}
                 </button>
             </div>
         </div>
     </div>
-    <div class="card stack" [class.visible]="selection.length > 1"></div>
-    <div class="selection-count" [class.visible]="selection.length > 1">
-        {{ 'asset.assets-selected-count' | translate: { count: selection.length } }}
+    <div class="card stack" [class.visible]="selectionManager.selection.length > 1"></div>
+    <div class="selection-count" [class.visible]="selectionManager.selection.length > 1">
+        {{ 'asset.assets-selected-count' | translate: { count: selectionManager.selection.length } }}
         <ul>
-            <li *ngFor="let asset of selection">{{ asset.name }}</li>
+            <li *ngFor="let asset of selectionManager.selection">{{ asset.name }}</li>
         </ul>
     </div>
 </div>

+ 4 - 7
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.scss

@@ -28,16 +28,13 @@
     position: relative;
 }
 
-.selected-checkbox {
-    opacity: 0;
+vdr-select-toggle {
     position: absolute;
-    color: var(--color-success-500);
-    background-color: white;
-    border-radius: 50%;
+    ::ng-deep .toggle {
+        box-shadow: 0px 5px 5px -4px rgba(0, 0, 0, 0.75);
+    }
     top: -12px;
     left: -12px;
-    box-shadow: 0px 5px 5px -4px rgba(0, 0, 0, 0.75);
-    transition: opacity 0.1s;
 }
 
 .card.selected {

+ 21 - 36
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts

@@ -1,5 +1,6 @@
-import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
 import { ModalService } from '../../../providers/modal/modal.service';
+import { SelectionManager } from '../../../common/utilities/selection-manager';
 import { AssetPreviewDialogComponent } from '../asset-preview-dialog/asset-preview-dialog.component';
 import { AssetLike } from './asset-gallery.types';
 
@@ -19,13 +20,17 @@ export class AssetGalleryComponent implements OnChanges {
     @Output() selectionChange = new EventEmitter<AssetLike[]>();
     @Output() deleteAssets = new EventEmitter<AssetLike[]>();
 
-    selection: AssetLike[] = [];
+    selectionManager = new SelectionManager<AssetLike>({
+        multiSelect: this.multiSelect,
+        itemsAreEqual: (a, b) => a.id === b.id,
+        additiveMode: false,
+    });
 
     constructor(private modalService: ModalService) {}
 
-    ngOnChanges() {
+    ngOnChanges(changes: SimpleChanges) {
         if (this.assets) {
-            for (const asset of this.selection) {
+            for (const asset of this.selectionManager.selection) {
                 // Update and selected assets with any changes
                 const match = this.assets.find(a => a.id === asset.id);
                 if (match) {
@@ -33,50 +38,30 @@ export class AssetGalleryComponent implements OnChanges {
                 }
             }
         }
+        if (changes['assets']) {
+            this.selectionManager.setCurrentItems(this.assets);
+        }
+        if (changes['multiSelect']) {
+            this.selectionManager.setMultiSelect(this.multiSelect);
+        }
     }
 
     toggleSelection(asset: AssetLike, event?: MouseEvent) {
-        const index = this.selection.findIndex(a => a.id === asset.id);
-        if (this.multiSelect && event?.shiftKey && 1 <= this.selection.length) {
-            const lastSelection = this.selection[this.selection.length - 1];
-            const lastSelectionIndex = this.assets.findIndex(a => a.id === lastSelection.id);
-            const currentIndex = this.assets.findIndex(a => a.id === asset.id);
-            const start = currentIndex < lastSelectionIndex ? currentIndex : lastSelectionIndex;
-            const end = currentIndex > lastSelectionIndex ? currentIndex + 1 : lastSelectionIndex;
-            this.selection.push(
-                ...this.assets.slice(start, end).filter(a => !this.selection.find(s => s.id === a.id)),
-            );
-        } else if (index === -1) {
-            if (this.multiSelect && (event?.ctrlKey || event?.shiftKey)) {
-                this.selection.push(asset);
-            } else {
-                this.selection = [asset];
-            }
-        } else {
-            if (this.multiSelect && event?.ctrlKey) {
-                this.selection.splice(index, 1);
-            } else if (1 < this.selection.length) {
-                this.selection = [asset];
-            } else {
-                this.selection.splice(index, 1);
-            }
-        }
-        // Make the selection mutable
-        this.selection = this.selection.map(x => ({ ...x }));
-        this.selectionChange.emit(this.selection);
+        this.selectionManager.toggleSelection(asset, event);
+        this.selectionChange.emit(this.selectionManager.selection);
     }
 
     selectMultiple(assets: AssetLike[]) {
-        this.selection = assets;
-        this.selectionChange.emit(this.selection);
+        this.selectionManager.selectMultiple(assets);
+        this.selectionChange.emit(this.selectionManager.selection);
     }
 
     isSelected(asset: AssetLike): boolean {
-        return !!this.selection.find(a => a.id === asset.id);
+        return this.selectionManager.isSelected(asset);
     }
 
     lastSelected(): AssetLike {
-        return this.selection[this.selection.length - 1];
+        return this.selectionManager.lastSelected();
     }
 
     previewAsset(asset: AssetLike) {

+ 5 - 2
packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.scss

@@ -2,13 +2,12 @@
     display: block;
     margin-bottom: 12px;
 
-    >.card {
+    > .card {
         margin-top: 6px;
     }
 }
 
 .operation-inputs {
-
     padding-top: 0;
 
     .arg-row:not(:last-child) {
@@ -27,4 +26,8 @@
     .hidden {
         display: none;
     }
+    label {
+        min-width: 130px;
+        display: inline-block;
+    }
 }

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

@@ -6,6 +6,7 @@ import {
     Input,
     OnChanges,
     OnDestroy,
+    OnInit,
     Output,
     SimpleChanges,
 } from '@angular/core';
@@ -22,7 +23,7 @@ import {
 } from '@angular/forms';
 import { ConfigArgType } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
-import { Subscription } from 'rxjs';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
 
 import { InputComponentConfig } from '../../../common/component-registry-types';
 import {
@@ -55,16 +56,21 @@ import { interpolateDescription } from '../../../common/utilities/interpolate-de
         },
     ],
 })
-export class ConfigurableInputComponent implements OnChanges, OnDestroy, ControlValueAccessor, Validator {
+export class ConfigurableInputComponent
+    implements OnInit, OnChanges, OnDestroy, ControlValueAccessor, Validator
+{
     @Input() operation?: ConfigurableOperation;
     @Input() operationDefinition?: ConfigurableOperationDefinition;
     @Input() readonly = false;
     @Input() removable = true;
+    @Input() position = 0;
     @Output() remove = new EventEmitter<ConfigurableOperation>();
     argValues: { [name: string]: any } = {};
     onChange: (val: any) => void;
     onTouch: () => void;
     form = new FormGroup({});
+    positionChange$: Observable<number>;
+    private positionChangeSubject = new BehaviorSubject<number>(0);
     private subscription: Subscription;
 
     interpolateDescription(): string {
@@ -75,10 +81,17 @@ export class ConfigurableInputComponent implements OnChanges, OnDestroy, Control
         }
     }
 
+    ngOnInit() {
+        this.positionChange$ = this.positionChangeSubject.asObservable();
+    }
+
     ngOnChanges(changes: SimpleChanges) {
         if ('operation' in changes || 'operationDefinition' in changes) {
             this.createForm();
         }
+        if ('position' in changes) {
+            this.positionChangeSubject.next(this.position);
+        }
     }
 
     ngOnDestroy() {

+ 92 - 0
packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.html

@@ -0,0 +1,92 @@
+<ng-template vdrDialogTitle>
+    <div class="title-row">
+        <span *ngIf="mode === 'product'">{{ 'common.select-products' | translate }}</span>
+        <span *ngIf="mode === 'variant'">{{ 'common.select-variants' | translate }}</span>
+    </div>
+</ng-template>
+<vdr-product-search-input
+    #productSearchInputComponent
+    [facetValueResults]="facetValues$ | async"
+    (searchTermChange)="setSearchTerm($event)"
+    (facetValueChange)="setFacetValueIds($event)"
+></vdr-product-search-input>
+<div class="flex-wrapper">
+    <div class="gallery">
+        <div
+            class="card"
+            *ngFor="let item of (items$ | async) || [] | paginate: paginationConfig; trackBy: trackByFn"
+            (click)="toggleSelection(item, $event)"
+            [class.selected]="isSelected(item)"
+        >
+            <div class="card-img">
+                <vdr-select-toggle
+                    [selected]="isSelected(item)"
+                    [disabled]="true"
+                    [hiddenWhenOff]="true"
+                ></vdr-select-toggle>
+                <img
+                    [src]="
+                        (mode === 'product'
+                            ? item.productAsset
+                            : item.productVariantAsset || item.productAsset
+                        ) | assetPreview: 'thumb'
+                    "
+                />
+            </div>
+            <div class="detail">
+                <span [title]="mode === 'product' ? item.productName : item.productVariantName">{{
+                    mode === 'product' ? item.productName : item.productVariantName
+                }}</span>
+                <div *ngIf="mode === 'variant'"><small>{{ item.sku }}</small></div>
+            </div>
+        </div>
+    </div>
+    <div class="selection">
+        <div class="m2 flex center">
+            <div>
+                {{ 'common.items-selected-count' | translate: { count: selectionManager.selection.length } }}
+            </div>
+            <div class="flex-spacer"></div>
+            <button class="btn btn-sm btn-link" (click)="clearSelection()">
+                <cds-icon shape="times"></cds-icon> {{ 'common.clear-selection' | translate }}
+            </button>
+        </div>
+        <div class="selected-items">
+            <div *ngFor="let item of selectionManager.selection" class="flex item-row">
+                <div class="">{{ mode === 'product' ? item.productName : item.productVariantName }}</div>
+                <div class="flex-spacer"></div>
+                <div>
+                    <button class="icon-button" (click)="toggleSelection(item, $event)">
+                        <cds-icon shape="times"></cds-icon>
+                    </button>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="paging-controls">
+    <vdr-items-per-page-controls
+        [itemsPerPage]="paginationConfig.itemsPerPage"
+        (itemsPerPageChange)="itemsPerPageChange($event)"
+    ></vdr-items-per-page-controls>
+
+    <vdr-pagination-controls
+        [currentPage]="paginationConfig.currentPage"
+        [itemsPerPage]="paginationConfig.itemsPerPage"
+        [totalItems]="paginationConfig.totalItems"
+        (pageChange)="pageChange($event)"
+    ></vdr-pagination-controls>
+</div>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button
+        type="submit"
+        (click)="select()"
+        class="btn btn-primary"
+        [disabled]="selectionManager.selection.length === 0"
+    >
+        {{ 'common.select-items-with-count' | translate: { count: selectionManager.selection.length } }}
+    </button>
+</ng-template>

+ 76 - 0
packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.scss

@@ -0,0 +1,76 @@
+:host {
+    display: flex;
+    flex-direction: column;
+    flex-direction: 1;
+    height: 70vh;
+}
+
+.flex-wrapper {
+    display: flex;
+    overflow-y: hidden;
+}
+
+.gallery {
+    /* autoprefixer: off */
+    flex: 1;
+    display: grid;
+    grid-template-columns: repeat(auto-fill, 125px);
+    grid-template-rows: repeat(auto-fill, 200px);
+    grid-gap: 10px 20px;
+    padding-left: 12px;
+    padding-top: 12px;
+    padding-bottom: 64px;
+    overflow-y: auto;
+
+    .card:hover {
+        box-shadow: 0 0.125rem 0 0 var(--color-primary-500);
+        border: 1px solid var(--color-primary-500);
+    }
+}
+
+.detail {
+    margin: 0 3px;
+    font-size: 12px;
+    line-height: 0.8rem;
+}
+
+vdr-select-toggle {
+    position: absolute;
+    ::ng-deep .toggle {
+        box-shadow: 0px 5px 5px -4px rgba(0, 0, 0, 0.75);
+    }
+    top: -12px;
+    left: -12px;
+}
+
+.card.selected {
+    box-shadow: 0 0.125rem 0 0 var(--color-primary-500);
+    border: 1px solid var(--color-primary-500);
+
+    .selected-checkbox {
+        opacity: 1;
+    }
+}
+
+.selection {
+    width: 23vw;
+    max-width: 400px;
+    padding: 6px;
+    display: flex;
+    flex-direction: column;
+    .selected-items {
+        flex: 1;
+        overflow-y: auto;
+        .item-row {
+            padding-left: 3px;
+            &:hover {
+                background-color: var(--color-component-bg-200);
+            }
+        }
+    }
+}
+.paging-controls {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}

+ 167 - 0
packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.ts

@@ -0,0 +1,167 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import { PaginationInstance } from 'ngx-pagination';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
+import { map, tap } from 'rxjs/operators';
+
+import { SearchProductsQuery } from '../../../common/generated-types';
+import { SelectionManager } from '../../../common/utilities/selection-manager';
+import { DataService } from '../../../data/providers/data.service';
+import { Dialog } from '../../../providers/modal/modal.service';
+
+export type SearchItem = SearchProductsQuery['search']['items'][number];
+
+@Component({
+    selector: 'vdr-product-multi-selector-dialog',
+    templateUrl: './product-multi-selector-dialog.component.html',
+    styleUrls: ['./product-multi-selector-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductMultiSelectorDialogComponent implements OnInit, Dialog<SearchItem[]> {
+    mode: 'product' | 'variant' = 'product';
+    initialSelectionIds: string[] = [];
+    items$: Observable<SearchItem[]>;
+    facetValues$: Observable<SearchProductsQuery['search']['facetValues']>;
+    searchTerm$ = new BehaviorSubject<string>('');
+    searchFacetValueIds$ = new BehaviorSubject<string[]>([]);
+    paginationConfig: PaginationInstance = {
+        currentPage: 1,
+        itemsPerPage: 25,
+        totalItems: 1,
+    };
+    selectionManager: SelectionManager<SearchItem>;
+
+    resolveWith: (result?: SearchItem[]) => void;
+    private paginationConfig$ = new BehaviorSubject<PaginationInstance>(this.paginationConfig);
+
+    constructor(private dataService: DataService, private changeDetector: ChangeDetectorRef) {}
+
+    ngOnInit(): void {
+        const idFn =
+            this.mode === 'product'
+                ? (a: SearchItem, b: SearchItem) => a.productId === b.productId
+                : (a: SearchItem, b: SearchItem) => a.productVariantId === b.productVariantId;
+        this.selectionManager = new SelectionManager<SearchItem>({
+            multiSelect: true,
+            itemsAreEqual: idFn,
+            additiveMode: true,
+        });
+        const searchQueryResult = this.dataService.product.searchProducts(
+            '',
+            this.paginationConfig.itemsPerPage,
+            0,
+        );
+        const result$ = combineLatest(
+            this.searchTerm$,
+            this.searchFacetValueIds$,
+            this.paginationConfig$,
+        ).subscribe(([term, facetValueIds, pagination]) => {
+            const take = +pagination.itemsPerPage;
+            const skip = (pagination.currentPage - 1) * take;
+            return searchQueryResult.ref.refetch({
+                input: { skip, take, term, facetValueIds, groupByProduct: this.mode === 'product' },
+            });
+        });
+
+        this.items$ = searchQueryResult.stream$.pipe(
+            tap(data => {
+                this.paginationConfig.totalItems = data.search.totalItems;
+                this.selectionManager.setCurrentItems(data.search.items);
+            }),
+            map(data => data.search.items),
+        );
+
+        this.facetValues$ = searchQueryResult.stream$.pipe(map(data => data.search.facetValues));
+
+        if (this.initialSelectionIds.length) {
+            if (this.mode === 'product') {
+                this.dataService.product
+                    .getProducts({
+                        filter: {
+                            id: {
+                                in: this.initialSelectionIds,
+                            },
+                        },
+                    })
+                    .single$.subscribe(({ products }) => {
+                        this.selectionManager.selectMultiple(
+                            products.items.map(
+                                product =>
+                                    ({
+                                        productId: product.id,
+                                        productName: product.name,
+                                    } as SearchItem),
+                            ),
+                        );
+                        this.changeDetector.markForCheck();
+                    });
+            } else {
+                this.dataService.product
+                    .getProductVariants({
+                        filter: {
+                            id: {
+                                in: this.initialSelectionIds,
+                            },
+                        },
+                    })
+                    .single$.subscribe(({ productVariants }) => {
+                        this.selectionManager.selectMultiple(
+                            productVariants.items.map(
+                                variant =>
+                                    ({
+                                        productVariantId: variant.id,
+                                        productVariantName: variant.name,
+                                    } as SearchItem),
+                            ),
+                        );
+                        this.changeDetector.markForCheck();
+                    });
+            }
+        }
+    }
+
+    trackByFn(index: number, item: SearchItem) {
+        return item.productId;
+    }
+
+    setSearchTerm(term: string) {
+        this.searchTerm$.next(term);
+    }
+    setFacetValueIds(ids: string[]) {
+        this.searchFacetValueIds$.next(ids);
+    }
+
+    toggleSelection(item: SearchItem, event: MouseEvent) {
+        this.selectionManager.toggleSelection(item, event);
+    }
+
+    clearSelection() {
+        this.selectionManager.selectMultiple([]);
+    }
+
+    isSelected(item: SearchItem) {
+        return this.selectionManager.isSelected(item);
+    }
+
+    entityInfoClick(event: MouseEvent) {
+        event.preventDefault();
+        event.stopPropagation();
+    }
+
+    pageChange(page: number) {
+        this.paginationConfig.currentPage = page;
+        this.paginationConfig$.next(this.paginationConfig);
+    }
+
+    itemsPerPageChange(itemsPerPage: number) {
+        this.paginationConfig.itemsPerPage = itemsPerPage;
+        this.paginationConfig$.next(this.paginationConfig);
+    }
+
+    select() {
+        this.resolveWith(this.selectionManager.selection);
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+}

+ 0 - 0
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.html → packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.html


+ 0 - 0
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.scss → packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.scss


+ 3 - 1
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts → packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.ts

@@ -1,8 +1,10 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
 import { NgSelectComponent, SELECTION_MODEL_FACTORY } from '@ng-select/ng-select';
-import { SearchProductsQuery, SingleSearchSelectionModelFactory } from '@vendure/admin-ui/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
+import { SearchProductsQuery } from '../../../common/generated-types';
+import { SingleSearchSelectionModelFactory } from '../../../common/single-search-selection-model';
+
 type FacetValueResult = SearchProductsQuery['search']['facetValues'][number];
 
 @Component({

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/select-toggle/select-toggle.component.html

@@ -1,5 +1,6 @@
 <div
     class="toggle"
+    [class.hide-when-off]="hiddenWhenOff"
     [class.disabled]="disabled"
     [class.small]="size === 'small'"
     [attr.tabindex]="disabled ? null : 0"
@@ -8,7 +9,7 @@
     (keydown.space)="$event.preventDefault(); selectedChange.emit(!selected)"
     (click)="selectedChange.emit(!selected)"
 >
-    <clr-icon shape="check" [attr.size]="size === 'small' ? 16 : 32"></clr-icon>
+    <clr-icon shape="check-circle" [attr.size]="size === 'small' ? 24 : 32"></clr-icon>
 </div>
 <div class="toggle-label" [class.disabled]="disabled" *ngIf="label" (click)="selectedChange.emit(!selected)">
     {{ label }}

+ 14 - 16
packages/admin-ui/src/lib/core/src/shared/components/select-toggle/select-toggle.component.scss

@@ -10,17 +10,16 @@
 .toggle {
     @include no-select();
     cursor: pointer;
+    color: var(--color-grey-300);
     background-color: var(--color-component-bg-100);
-    border: 2px solid var(--color-component-border-300);
-    padding: 0 6px;
     border-radius: 50%;
-    width: 32px;
-    height: 32px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    color: var(--color-grey-300);
-    transition: background-color 0.2s, border 0.2s;
+    top: -12px;
+    left: -12px;
+    transition: opacity 0.2s, color 0.2s;
+
+    &.hide-when-off {
+        opacity: 0;
+    }
 
     &.small {
         width: 24px;
@@ -28,19 +27,16 @@
     }
 
     &:not(.disabled):hover {
-        border-color: var(--color-success-500);
-        background-color: var(--color-success-400);
+        color: var(--color-success-400);
         opacity: 0.9;
     }
 
     &.selected {
-        background-color: var(--color-success-500);
-        border-color: var(--color-success-600);
-        color: white;
+        opacity: 1;
+        color: var(--color-success-500);
 
         &:not(.disabled):hover {
-            background-color: var(--color-success-500);
-            border-color: var(--color-success-400);
+            color: var(--color-success-400);
             opacity: 0.9;
         }
     }
@@ -54,11 +50,13 @@
         cursor: default;
     }
 }
+
 .toggle-label {
     flex: 1;
     margin-left: 6px;
     text-align: left;
     font-size: 12px;
+
     &:not(.disabled) {
         cursor: pointer;
     }

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/select-toggle/select-toggle.component.ts

@@ -12,6 +12,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output
 export class SelectToggleComponent {
     @Input() size: 'small' | 'large' = 'large';
     @Input() selected = false;
+    @Input() hiddenWhenOff = false;
     @Input() disabled = false;
     @Input() label: string | undefined;
     @Output() selectedChange = new EventEmitter<boolean>();

+ 21 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component.html

@@ -0,0 +1,21 @@
+<ng-container *ngIf="selectable$ | async; else default">
+    <div class="btn-group btn-outline-primary btn-sm mode-select">
+        <button
+            class="btn"
+            (click)="setCombinationModeAnd()"
+            [class.btn-primary]="formControl.value === true"
+        >
+            {{ 'common.boolean-and' | translate }}
+        </button>
+        <button
+            class="btn"
+            (click)="setCombinationModeOr()"
+            [class.btn-primary]="formControl.value === false"
+        >
+            {{ 'common.boolean-or' | translate }}
+        </button>
+    </div>
+</ng-container>
+<ng-template #default>
+    <small>{{ 'common.not-applicable' | translate }}</small>
+</ng-template>

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

@@ -0,0 +1,4 @@
+
+.mode-select {
+    text-transform: uppercase;
+}

+ 51 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component.ts

@@ -0,0 +1,51 @@
+import { ChangeDetectionStrategy, Component, Optional } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { ConfigurableInputComponent } from '@vendure/admin-ui/core';
+import { DefaultFormComponentConfig, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+import { Observable, of } from 'rxjs';
+import { map, tap } from 'rxjs/operators';
+
+import { FormInputComponent, InputComponentConfig } from '../../../common/component-registry-types';
+
+/**
+ * @description
+ * A special input used to display the "Combination mode" AND/OR toggle.
+ *
+ * @docsCategory custom-input-components
+ * @docsPage default-inputs
+ */
+@Component({
+    selector: 'vdr-combination-mode-form-input',
+    templateUrl: './combination-mode-form-input.component.html',
+    styleUrls: ['./combination-mode-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CombinationModeFormInputComponent implements FormInputComponent {
+    static readonly id: DefaultFormComponentId = 'combination-mode-form-input';
+    readonly: boolean;
+    formControl: FormControl;
+    config: DefaultFormComponentConfig<'combination-mode-form-input'>;
+    selectable$: Observable<boolean>;
+
+    constructor(@Optional() private configurableInputComponent: ConfigurableInputComponent) {
+        const selectable$ = configurableInputComponent
+            ? configurableInputComponent.positionChange$.pipe(map(position => 0 < position))
+            : of(true);
+
+        this.selectable$ = selectable$.pipe(
+            tap(selectable => {
+                if (!selectable) {
+                    this.setCombinationModeAnd();
+                }
+            }),
+        );
+    }
+
+    setCombinationModeAnd() {
+        this.formControl.setValue(true);
+    }
+
+    setCombinationModeOr() {
+        this.formControl.setValue(false);
+    }
+}

+ 5 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component.html

@@ -0,0 +1,5 @@
+<div class="flex">
+    <button (click)="select()" class="btn btn-sm btn-secondary">
+        {{ 'common.items-selected-count' | translate: { count: formControl.value?.length ?? 0 } }}...
+    </button>
+</div>

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


+ 54 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component.ts

@@ -0,0 +1,54 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { DefaultFormComponentConfig, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+
+import { FormInputComponent } from '../../../common/component-registry-types';
+import { DataService } from '../../../data/providers/data.service';
+import { ModalService } from '../../../providers/modal/modal.service';
+import { ProductMultiSelectorDialogComponent } from '../../components/product-multi-selector-dialog/product-multi-selector-dialog.component';
+
+@Component({
+    selector: 'vdr-product-multi-selector-form-input',
+    templateUrl: './product-multi-selector-form-input.component.html',
+    styleUrls: ['./product-multi-selector-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductMultiSelectorFormInputComponent implements OnInit, FormInputComponent {
+    @Input() config: DefaultFormComponentConfig<'product-multi-form-input'>;
+    @Input() formControl: FormControl;
+    @Input() readonly: boolean;
+    mode: 'product' | 'variant' = 'product';
+    readonly isListInput = true;
+    static readonly id: DefaultFormComponentId = 'product-multi-form-input';
+
+    constructor(
+        private modalService: ModalService,
+        private dataService: DataService,
+        private changeDetector: ChangeDetectorRef,
+    ) {}
+
+    ngOnInit() {
+        this.mode = this.config.ui?.selectionMode ?? 'product';
+    }
+
+    select() {
+        this.modalService
+            .fromComponent(ProductMultiSelectorDialogComponent, {
+                size: 'xl',
+                locals: {
+                    mode: this.mode,
+                    initialSelectionIds: this.formControl.value,
+                },
+            })
+            .subscribe(selection => {
+                if (selection) {
+                    this.formControl.setValue(
+                        selection.map(item =>
+                            this.mode === 'product' ? item.productId : item.productVariantId,
+                        ),
+                    );
+                    this.changeDetector.markForCheck();
+                }
+            });
+    }
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts

@@ -10,12 +10,14 @@ import {
 
 import { BooleanFormInputComponent } from './boolean-form-input/boolean-form-input.component';
 import { JsonEditorFormInputComponent } from './code-editor-form-input/json-editor-form-input.component';
+import { CombinationModeFormInputComponent } from './combination-mode-form-input/combination-mode-form-input.component';
 import { CurrencyFormInputComponent } from './currency-form-input/currency-form-input.component';
 import { CustomerGroupFormInputComponent } from './customer-group-form-input/customer-group-form-input.component';
 import { DateFormInputComponent } from './date-form-input/date-form-input.component';
 import { FacetValueFormInputComponent } from './facet-value-form-input/facet-value-form-input.component';
 import { NumberFormInputComponent } from './number-form-input/number-form-input.component';
 import { PasswordFormInputComponent } from './password-form-input/password-form-input.component';
+import { ProductMultiSelectorFormInputComponent } from './product-multi-selector-form-input/product-multi-selector-form-input.component';
 import { ProductSelectorFormInputComponent } from './product-selector-form-input/product-selector-form-input.component';
 import { RelationFormInputComponent } from './relation-form-input/relation-form-input.component';
 import { RichTextFormInputComponent } from './rich-text-form-input/rich-text-form-input.component';
@@ -38,6 +40,8 @@ export const defaultFormInputs = [
     TextareaFormInputComponent,
     RichTextFormInputComponent,
     JsonEditorFormInputComponent,
+    ProductMultiSelectorFormInputComponent,
+    CombinationModeFormInputComponent,
 ];
 
 /**

+ 11 - 8
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts

@@ -1,8 +1,10 @@
 import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 import { FormControl } from '@angular/forms';
+import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
 import { Observable, of } from 'rxjs';
 import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
 
+import { FormInputComponent } from '../../../../common/component-registry-types';
 import { GetAssetQuery, RelationCustomFieldConfig } from '../../../../common/generated-types';
 import { DataService } from '../../../../data/providers/data.service';
 import { ModalService } from '../../../../providers/modal/modal.service';
@@ -15,17 +17,18 @@ import { AssetPreviewDialogComponent } from '../../../components/asset-preview-d
     styleUrls: ['./relation-asset-input.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class RelationAssetInputComponent implements OnInit {
+export class RelationAssetInputComponent implements FormInputComponent, OnInit {
+    static readonly id: DefaultFormComponentId = 'asset-form-input';
     @Input() readonly: boolean;
-    @Input() parentFormControl: FormControl;
+    @Input('parentFormControl') formControl: FormControl;
     @Input() config: RelationCustomFieldConfig;
     asset$: Observable<GetAssetQuery['asset'] | undefined>;
 
     constructor(private modalService: ModalService, private dataService: DataService) {}
 
     ngOnInit() {
-        this.asset$ = this.parentFormControl.valueChanges.pipe(
-            startWith(this.parentFormControl.value),
+        this.asset$ = this.formControl.valueChanges.pipe(
+            startWith(this.formControl.value),
             map(asset => asset?.id),
             distinctUntilChanged(),
             switchMap(id => {
@@ -48,15 +51,15 @@ export class RelationAssetInputComponent implements OnInit {
             })
             .subscribe(result => {
                 if (result && result.length) {
-                    this.parentFormControl.setValue(result[0]);
-                    this.parentFormControl.markAsDirty();
+                    this.formControl.setValue(result[0]);
+                    this.formControl.markAsDirty();
                 }
             });
     }
 
     remove() {
-        this.parentFormControl.setValue(null);
-        this.parentFormControl.markAsDirty();
+        this.formControl.setValue(null);
+        this.formControl.markAsDirty();
     }
 
     previewAsset(asset: NonNullable<GetAssetQuery['asset']>) {

+ 8 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -69,6 +69,8 @@ import { ModalDialogComponent } from './components/modal-dialog/modal-dialog.com
 import { ObjectTreeComponent } from './components/object-tree/object-tree.component';
 import { OrderStateLabelComponent } from './components/order-state-label/order-state-label.component';
 import { PaginationControlsComponent } from './components/pagination-controls/pagination-controls.component';
+import { ProductMultiSelectorDialogComponent } from './components/product-multi-selector-dialog/product-multi-selector-dialog.component';
+import { ProductSearchInputComponent } from './components/product-search-input/product-search-input.component';
 import { ProductVariantSelectorComponent } from './components/product-variant-selector/product-variant-selector.component';
 import { ExternalImageDialogComponent } from './components/rich-text-editor/external-image-dialog/external-image-dialog.component';
 import { LinkDialogComponent } from './components/rich-text-editor/link-dialog/link-dialog.component';
@@ -88,6 +90,7 @@ import { IfMultichannelDirective } from './directives/if-multichannel.directive'
 import { IfPermissionsDirective } from './directives/if-permissions.directive';
 import { BooleanFormInputComponent } from './dynamic-form-inputs/boolean-form-input/boolean-form-input.component';
 import { JsonEditorFormInputComponent } from './dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component';
+import { CombinationModeFormInputComponent } from './dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component';
 import { CurrencyFormInputComponent } from './dynamic-form-inputs/currency-form-input/currency-form-input.component';
 import { CustomerGroupFormInputComponent } from './dynamic-form-inputs/customer-group-form-input/customer-group-form-input.component';
 import { DateFormInputComponent } from './dynamic-form-inputs/date-form-input/date-form-input.component';
@@ -95,6 +98,7 @@ import { DynamicFormInputComponent } from './dynamic-form-inputs/dynamic-form-in
 import { FacetValueFormInputComponent } from './dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component';
 import { NumberFormInputComponent } from './dynamic-form-inputs/number-form-input/number-form-input.component';
 import { PasswordFormInputComponent } from './dynamic-form-inputs/password-form-input/password-form-input.component';
+import { ProductMultiSelectorFormInputComponent } from './dynamic-form-inputs/product-multi-selector-form-input/product-multi-selector-form-input.component';
 import { ProductSelectorFormInputComponent } from './dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component';
 import { RelationAssetInputComponent } from './dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component';
 import { RelationCustomerInputComponent } from './dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component';
@@ -234,6 +238,8 @@ const DECLARATIONS = [
     UiExtensionPointComponent,
     CustomDetailComponentHostComponent,
     AssetPreviewLinksComponent,
+    ProductMultiSelectorDialogComponent,
+    ProductSearchInputComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [
@@ -258,6 +264,8 @@ const DYNAMIC_FORM_INPUTS = [
     TextareaFormInputComponent,
     RichTextFormInputComponent,
     JsonEditorFormInputComponent,
+    ProductMultiSelectorFormInputComponent,
+    CombinationModeFormInputComponent,
 ];
 
 @NgModule({

+ 2 - 0
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html

@@ -87,6 +87,7 @@
             <ng-container *ngFor="let condition of conditions; index as i">
                 <vdr-configurable-input
                     (remove)="removeCondition($event)"
+                    [position]="i"
                     [readonly]="!('UpdatePromotion' | hasPermission)"
                     [operation]="condition"
                     [operationDefinition]="getConditionDefinition(condition)"
@@ -118,6 +119,7 @@
             <vdr-configurable-input
                 *ngFor="let action of actions; index as i"
                 (remove)="removeAction($event)"
+                [position]="i"
                 [operation]="action"
                 [readonly]="!('UpdatePromotion' | hasPermission)"
                 [operationDefinition]="getActionDefinition(action)"

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Přidat {count, plural, one {variantu} few {{count} varianty} other {{count} variant}}",
     "add-note": "Přidat poznámku",
     "available-languages": "Dostupné jazyky",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Zrušit",
     "cancel-navigation": "Zrušit navigaci",
     "change-selection": "",
     "channel": "Kanál",
     "channels": "Kanály",
+    "clear-selection": "",
     "close": "",
     "code": "Kód",
     "collapse-entries": "Schovat vstupy",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Host",
     "items-per-page-option": "{ count } na stránku",
+    "items-selected-count": "",
     "language": "Jazyk",
     "launch-extension": "Spustit rozšíření",
     "live-update": "Živé aktualizace",
@@ -219,6 +223,7 @@
     "more": "Více...",
     "name": "jméno",
     "no-results": "Žádné výsledky",
+    "not-applicable": "",
     "not-set": "Nenastaveno",
     "notify-create-error": "Vyskytla se chyba, nebylo vytvořeno: { entity }",
     "notify-create-success": "Vytvořeno: { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Vybrat...",
     "select-display-language": "Vyberte jazyk",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Vybrat dnešní datum",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -172,12 +172,15 @@
     "add-new-variants": "{count, plural, one {1 Variante} other {{count} Varianten}} hinzufügen",
     "add-note": "Notiz hinzufügen",
     "available-languages": "Verfügbare Sprachen",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Abbrechen",
     "cancel-navigation": "Navigation abbrechen",
     "change-selection": "Auswahl ändern",
     "channel": "Kanal",
     "channels": "Kanäle",
+    "clear-selection": "",
     "close": "",
     "code": "Code",
     "collapse-entries": "Einträge einklappen",
@@ -205,6 +208,7 @@
     "general": "",
     "guest": "Gast",
     "items-per-page-option": "{ count } pro Seite",
+    "items-selected-count": "",
     "language": "Sprache",
     "launch-extension": "Erweiterung starten",
     "live-update": "Live-Aktualisierung",
@@ -217,6 +221,7 @@
     "more": "Mehr...",
     "name": "Name",
     "no-results": "Keine Ergebnisse",
+    "not-applicable": "",
     "not-set": "Nicht festgelegt",
     "notify-create-error": "Ein Fehler ist aufgetreten, { entity } konnte nicht erstellt werden",
     "notify-create-success": "{ entity } erstellt",
@@ -240,8 +245,11 @@
     "sample-formatting": "",
     "select": "Auswählen...",
     "select-display-language": "Anzeigesprache wählen",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Heute auswählen",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "Tags",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Add {count, plural, one {1 variant} other {{count} variants}}",
     "add-note": "Add note",
     "available-languages": "Available languages",
+    "boolean-and": "and",
+    "boolean-or": "or",
     "browser-default": "Browser default",
     "cancel": "Cancel",
     "cancel-navigation": "Cancel navigation",
     "change-selection": "Change selection",
     "channel": "Channel",
     "channels": "Channels",
+    "clear-selection": "Clear selection",
     "close": "Close",
     "code": "Code",
     "collapse-entries": "Collapse entries",
@@ -207,6 +210,7 @@
     "general": "General",
     "guest": "Guest",
     "items-per-page-option": "{ count } per page",
+    "items-selected-count": "{ count } {count, plural, one {item} other {items}} selected",
     "language": "Language",
     "launch-extension": "Launch extension",
     "live-update": "Live update",
@@ -219,6 +223,7 @@
     "more": "More...",
     "name": "Name",
     "no-results": "No results",
+    "not-applicable": "Not applicable",
     "not-set": "Not set",
     "notify-create-error": "An error occurred, could not create { entity }",
     "notify-create-success": "Created new { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "Sample formatting",
     "select": "Select...",
     "select-display-language": "Select display language",
+    "select-items-with-count": "Select { count } {count, plural, one {item} other {items}}",
+    "select-products": "Select products",
     "select-relation-id": "Select relation ID",
     "select-today": "Select today",
+    "select-variants": "Select variants",
     "set-language": "Set language",
     "short-date": "Short date",
     "tags": "Tags",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Añadir {count, plural, one {1 variante} other {{count} variantes}}",
     "add-note": "Añadir nota",
     "available-languages": "Idiomas disponibles",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Cancelar",
     "cancel-navigation": "Cancelar navegación",
     "change-selection": "Cambiar selección",
     "channel": "Canal de ventas",
     "channels": "Canales de ventas",
+    "clear-selection": "",
     "close": "Cerrar",
     "code": "Código",
     "collapse-entries": "Ocultar entradas",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Invitado",
     "items-per-page-option": "{ count } por página",
+    "items-selected-count": "",
     "language": "Idioma",
     "launch-extension": "Ejecutar extensión",
     "live-update": "Actualización en vivo",
@@ -219,6 +223,7 @@
     "more": "Más...",
     "name": "Nombre",
     "no-results": "Sin resultados",
+    "not-applicable": "",
     "not-set": "Sin fijar",
     "notify-create-error": "Ha ocurrido un problema, imposible de crear { entity }",
     "notify-create-success": "Creado nuevo { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Seleccionar...",
     "select-display-language": "Seleccionar idioma de interfaz",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Hoy",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "Etiquetas",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Ajout {count, plural, one {d'une variation} other {de {count} variations}}",
     "add-note": "Ajouter une note",
     "available-languages": "Langues disponibles",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Annuler",
     "cancel-navigation": "Annuler la navigation",
     "change-selection": "Modifier la sélection",
     "channel": "Canal",
     "channels": "Canaux",
+    "clear-selection": "",
     "close": "",
     "code": "Code",
     "collapse-entries": "Réduire les éléments",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Invité",
     "items-per-page-option": "{ count } par page",
+    "items-selected-count": "",
     "language": "Langue",
     "launch-extension": "Lancer extension",
     "live-update": "Mise à jour automatique",
@@ -219,6 +223,7 @@
     "more": "Plus...",
     "name": "Nom",
     "no-results": "Aucun resultat",
+    "not-applicable": "",
     "not-set": "Non défini",
     "notify-create-error": "Une erreur est survenue, création de { entity } échouée",
     "notify-create-success": "Nouveau { entity } créé",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Selectionner...",
     "select-display-language": "Choisir la langue d'affichage",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Choisir aujourd'hui",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "Mots-clés",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/it.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Aggiungi {count, plural, one {1 variante} other {{count} varianti}}",
     "add-note": "Aggiungi nota",
     "available-languages": "Lingue disponibili",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Annulla",
     "cancel-navigation": "Annulla navigazione",
     "change-selection": "Cambia selezione",
     "channel": "Canale",
     "channels": "Canali",
+    "clear-selection": "",
     "close": "Chiudi",
     "code": "Codice",
     "collapse-entries": "Riduci elementi",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Ospite",
     "items-per-page-option": "{ count } per pagina",
+    "items-selected-count": "",
     "language": "Lingua",
     "launch-extension": "Lancia estensione",
     "live-update": "Aggiornamenti live",
@@ -219,6 +223,7 @@
     "more": "Altri...",
     "name": "Nome",
     "no-results": "Nessun risultato",
+    "not-applicable": "",
     "not-set": "Non impostato",
     "notify-create-error": "Si è verificato un errore, impossibile creare { entity }",
     "notify-create-success": "Creato nuovo { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Seleziona...",
     "select-display-language": "Seleziona lingua",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Seleziona oggi",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "Tag",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Dodaj {count, plural, one {1 wariant} other {{count} wariantów}}",
     "add-note": "",
     "available-languages": "Dostępne języki",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Anuluj",
     "cancel-navigation": "Anuluj nawigacje",
     "change-selection": "",
     "channel": "Kanał",
     "channels": "Kanały",
+    "clear-selection": "",
     "close": "",
     "code": "Kod",
     "collapse-entries": "",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Gość",
     "items-per-page-option": "{ count } na stronę",
+    "items-selected-count": "",
     "language": "Język",
     "launch-extension": "Uruchom rozszerzenie",
     "live-update": "Aktualizacja live",
@@ -219,6 +223,7 @@
     "more": "Więcej...",
     "name": "Nazwa",
     "no-results": "Brak wyników",
+    "not-applicable": "",
     "not-set": "Nie ustawione",
     "notify-create-error": "Wystąpił błąd, nie można utworzyć { entity }",
     "notify-create-success": "Utworzono { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Wybrano...",
     "select-display-language": "Wybierz język",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Wybierz dzisiaj",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Adicionar {count, plural, one {1 variant} other {{count} variants}}",
     "add-note": "Adicionar nota",
     "available-languages": "Idiomas disponíveis",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Cancelar",
     "cancel-navigation": "Cancelar navegação",
     "change-selection": "",
     "channel": "Canal",
     "channels": "Canais",
+    "clear-selection": "",
     "close": "",
     "code": "Código",
     "collapse-entries": "Recolher entradas",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Convidado",
     "items-per-page-option": "{ count } por página",
+    "items-selected-count": "",
     "language": "Idioma",
     "launch-extension": "Iniciar extensão",
     "live-update": "Atualização ao vivo",
@@ -219,6 +223,7 @@
     "more": "Mais...",
     "name": "Nome",
     "no-results": "Sem resultados",
+    "not-applicable": "",
     "not-set": "Não configurado",
     "notify-create-error": "Ocorreu um erro, não foi possível criar { entity }",
     "notify-create-success": "Criado novo { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Selecione...",
     "select-display-language": "Selecionar idioma de exibição",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Selecione hoje",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Adicionar {count, plural, one {variante} other {{count} variantes}}",
     "add-note": "Adicionar nota",
     "available-languages": "Idiomas disponíveis",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "Navegador padrão",
     "cancel": "Cancelar",
     "cancel-navigation": "Continuar a editar",
     "change-selection": "Alterar seleccionados",
     "channel": "Canal",
     "channels": "Canais",
+    "clear-selection": "",
     "close": "Fechar",
     "code": "Código",
     "collapse-entries": "Recolher entradas",
@@ -207,6 +210,7 @@
     "general": "Geral",
     "guest": "Convidado",
     "items-per-page-option": "{ count } por página",
+    "items-selected-count": "",
     "language": "Idioma",
     "launch-extension": "Iniciar extensão",
     "live-update": "Actualização em tempo real",
@@ -219,6 +223,7 @@
     "more": "Mais...",
     "name": "Nome",
     "no-results": "Nenhum resultado encontrado",
+    "not-applicable": "",
     "not-set": "Não configurado",
     "notify-create-error": "Ocorreu um erro. Não foi possível criar { entity }",
     "notify-create-success": "Novo(a) { entity } adicionado(a)",
@@ -242,8 +247,11 @@
     "sample-formatting": "Formatação de amostra",
     "select": "Seleccione...",
     "select-display-language": "Seleccionar idioma",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Seleccione a data de hoje",
+    "select-variants": "",
     "set-language": "Definir idioma",
     "short-date": "Data abreviada",
     "tags": "Tags",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/ru.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Добавить {count, plural, one {1 вариант} other {{count} вариантов}}",
     "add-note": "Добавить заметку",
     "available-languages": "Доступные языки",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Отмена",
     "cancel-navigation": "Отменить навигацию",
     "change-selection": "Изменить выбор",
     "channel": "Канал",
     "channels": "Каналы",
+    "clear-selection": "",
     "close": "Закрыть",
     "code": "Код",
     "collapse-entries": "Свернуть записи",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Гость",
     "items-per-page-option": "{ count } на странице",
+    "items-selected-count": "",
     "language": "Язык",
     "launch-extension": "Запуск расширения",
     "live-update": "Обновление в режиме реального времени",
@@ -219,6 +223,7 @@
     "more": "Больше...",
     "name": "Имя",
     "no-results": "Нет результатов",
+    "not-applicable": "",
     "not-set": "Не задано",
     "notify-create-error": "Ошибка, не удалось создать { entity }",
     "notify-create-success": "Создано новое { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Выбрать...",
     "select-display-language": "Выберите язык отображения",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Выберите сегодня",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "Теги",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/uk.json

@@ -174,12 +174,15 @@
     "add-new-variants": "Додати {count, plural, one {1 варіант} other {{count} варіантів}}",
     "add-note": "Додати замітку",
     "available-languages": "Доступні мови",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "Скасування",
     "cancel-navigation": "Скасувати навігацію",
     "change-selection": "Змінити вибір",
     "channel": "Канал",
     "channels": "Канали",
+    "clear-selection": "",
     "close": "Закрити",
     "code": "Код",
     "collapse-entries": "Згорнути записи",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "Гість",
     "items-per-page-option": "{ count } на сторінці",
+    "items-selected-count": "",
     "language": "Мова",
     "launch-extension": "Запуск розширення",
     "live-update": "Оновлення в режимі реального часу",
@@ -219,6 +223,7 @@
     "more": "Більше...",
     "name": "Ім'я",
     "no-results": "Немає результатів",
+    "not-applicable": "",
     "not-set": "Не задано",
     "notify-create-error": "Помилка, не вдалося створити { entity }",
     "notify-create-success": "Створено нове { entity }",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "Вибрати...",
     "select-display-language": "Виберіть мову відображення",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "Виберіть сьогодні",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "Теги",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -174,12 +174,15 @@
     "add-new-variants": "添加{count}个商品规格",
     "add-note": "添加注释",
     "available-languages": "可用语言",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "取消",
     "cancel-navigation": "取消",
     "change-selection": "更改选项",
     "channel": "销售渠道",
     "channels": "销售渠道",
+    "clear-selection": "",
     "close": "",
     "code": "编码",
     "collapse-entries": "收起",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "游客",
     "items-per-page-option": "每页显示 { count } 条",
+    "items-selected-count": "",
     "language": "语言",
     "launch-extension": "启动扩展插件",
     "live-update": "在线更新",
@@ -219,6 +223,7 @@
     "more": "更多...",
     "name": "名称",
     "no-results": "没找到任何结果",
+    "not-applicable": "",
     "not-set": "未设置",
     "notify-create-error": "添加{ entity }失败",
     "notify-create-success": "{ entity }已添加",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "选择...",
     "select-display-language": "选择显示语言",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "选择今天",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "标签",

+ 8 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -174,12 +174,15 @@
     "add-new-variants": "新增{count}個商品規格",
     "add-note": "",
     "available-languages": "可用語言",
+    "boolean-and": "",
+    "boolean-or": "",
     "browser-default": "",
     "cancel": "取消",
     "cancel-navigation": "取消",
     "change-selection": "",
     "channel": "渠道",
     "channels": "渠道",
+    "clear-selection": "",
     "close": "",
     "code": "編碼",
     "collapse-entries": "",
@@ -207,6 +210,7 @@
     "general": "",
     "guest": "游客",
     "items-per-page-option": "每页顯示 { count } 條",
+    "items-selected-count": "",
     "language": "語言",
     "launch-extension": "启動扩展插件",
     "live-update": "",
@@ -219,6 +223,7 @@
     "more": "更多...",
     "name": "名稱",
     "no-results": "没找到任何結果",
+    "not-applicable": "",
     "not-set": "未設定",
     "notify-create-error": "新增{ entity }失敗",
     "notify-create-success": "{ entity }已新增",
@@ -242,8 +247,11 @@
     "sample-formatting": "",
     "select": "選擇...",
     "select-display-language": "選擇顯示語言",
+    "select-items-with-count": "",
+    "select-products": "",
     "select-relation-id": "",
     "select-today": "選擇今天",
+    "select-variants": "",
     "set-language": "",
     "short-date": "",
     "tags": "",

+ 9 - 1
packages/common/src/shared-types.ts

@@ -143,7 +143,10 @@ export type DefaultFormComponentId =
     | 'rich-text-form-input'
     | 'select-form-input'
     | 'text-form-input'
-    | 'textarea-form-input';
+    | 'textarea-form-input'
+    | 'asset-form-input'
+    | 'product-multi-form-input'
+    | 'combination-mode-form-input';
 
 /**
  * @description
@@ -170,6 +173,11 @@ type DefaultFormConfigHash = {
     'textarea-form-input': {
         spellcheck?: boolean;
     };
+    'asset-form-input': {};
+    'product-multi-form-input': {
+        selectionMode?: 'product' | 'variant';
+    };
+    'combination-mode-form-input': {};
 };
 
 export type DefaultFormComponentUiConfig<T extends DefaultFormComponentId | string> =

+ 88 - 1
packages/core/e2e/collection.e2e-spec.ts

@@ -12,6 +12,7 @@ import path from 'path';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { pick } from '../../common/lib/pick';
+import { productIdCollectionFilter, variantIdCollectionFilter } from '../src/index';
 
 import { COLLECTION_FRAGMENT, FACET_VALUE_FRAGMENT } from './graphql/fragments';
 import {
@@ -1445,6 +1446,92 @@ describe('Collection resolver', () => {
             });
         });
 
+        describe('variantId filter', () => {
+            it('contains expects variants', async () => {
+                const { createCollection } = await adminClient.query<
+                    CreateCollection.Mutation,
+                    CreateCollection.Variables
+                >(CREATE_COLLECTION, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: `variantId filter test`,
+                                description: '',
+                                slug: `variantId-filter-test`,
+                            },
+                        ],
+                        filters: [
+                            {
+                                code: variantIdCollectionFilter.code,
+                                arguments: [
+                                    {
+                                        name: 'variantIds',
+                                        value: `["T_1", "T_4"]`,
+                                    },
+                                ],
+                            },
+                        ],
+                    },
+                });
+                await awaitRunningJobs(adminClient, 5000);
+
+                const result = await adminClient.query<
+                    GetCollectionProducts.Query,
+                    GetCollectionProducts.Variables
+                >(GET_COLLECTION_PRODUCT_VARIANTS, {
+                    id: createCollection.id,
+                });
+                expect(result.collection!.productVariants.items.map(i => i.id).sort()).toEqual([
+                    'T_1',
+                    'T_4',
+                ]);
+            });
+        });
+
+        describe('productId filter', () => {
+            it('contains expects variants', async () => {
+                const { createCollection } = await adminClient.query<
+                    CreateCollection.Mutation,
+                    CreateCollection.Variables
+                >(CREATE_COLLECTION, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: `productId filter test`,
+                                description: '',
+                                slug: `productId-filter-test`,
+                            },
+                        ],
+                        filters: [
+                            {
+                                code: productIdCollectionFilter.code,
+                                arguments: [
+                                    {
+                                        name: 'productIds',
+                                        value: `["T_2"]`,
+                                    },
+                                ],
+                            },
+                        ],
+                    },
+                });
+                await awaitRunningJobs(adminClient, 5000);
+
+                const result = await adminClient.query<
+                    GetCollectionProducts.Query,
+                    GetCollectionProducts.Variables
+                >(GET_COLLECTION_PRODUCT_VARIANTS, {
+                    id: createCollection.id,
+                });
+                expect(result.collection!.productVariants.items.map(i => i.id).sort()).toEqual([
+                    'T_5',
+                    'T_6',
+                ]);
+            });
+        });
+
         describe('re-evaluation of contents on changes', () => {
             let products: GetProductsWithVariantIdsQuery['products']['items'];
 
@@ -1886,7 +1973,7 @@ describe('Collection resolver', () => {
                     name: 'endsWith camera',
                 },
                 {
-                    id: 'T_23',
+                    id: 'T_25',
                     name: 'pear electronics',
                 },
             ]);

+ 114 - 15
packages/core/src/config/catalog/default-collection-filters.ts

@@ -1,11 +1,42 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { customAlphabet } from 'nanoid';
 
+import { ConfigArgDef } from '../../common/configurable-operation';
 import { UserInputError } from '../../common/error/errors';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 
 import { CollectionFilter } from './collection-filter';
 
+/**
+ * @description
+ * Used to created unique key names for DB query parameters, to avoid conflicts if the
+ * same filter is applied multiple times.
+ */
+export function randomSuffix(prefix: string) {
+    const nanoid = customAlphabet('123456789abcdefghijklmnopqrstuvwxyz', 6);
+    return `${prefix}_${nanoid()}`;
+}
+
+/**
+ * @description
+ * Add this to your CollectionFilter `args` object to display the standard UI component
+ * for selecting the combination mode when working with multiple filters.
+ */
+export const combineWithAndArg: ConfigArgDef<'boolean'> = {
+    type: 'boolean',
+    label: [{ languageCode: LanguageCode.en, value: 'Combination mode' }],
+    description: [
+        {
+            languageCode: LanguageCode.en,
+            value: 'If this filter is being combined with other filters, do all conditions need to be satisfied (AND), or just one or the other (OR)?',
+        },
+    ],
+    defaultValue: true,
+    ui: {
+        component: 'combination-mode-form-input',
+    },
+};
+
 /**
  * Filters for ProductVariants having the given facetValueIds (including parent Product)
  */
@@ -29,6 +60,7 @@ export const facetValueCollectionFilter = new CollectionFilter({
                 },
             ],
         },
+        combineWithAnd: combineWithAndArg,
     },
     code: 'facet-value-filter',
     description: [{ languageCode: LanguageCode.en, value: 'Filter by facet values' }],
@@ -70,13 +102,21 @@ export const facetValueCollectionFilter = new CollectionFilter({
                 .select('variant_ids_table.variant_id')
                 .from(`(${union.getQuery()})`, 'variant_ids_table');
 
-            qb.andWhere(`productVariant.id IN (${variantIds.getQuery()})`).setParameters({
+            const clause = `productVariant.id IN (${variantIds.getQuery()})`;
+            const params = {
                 [idsName]: ids,
                 [countName]: args.containsAny ? 1 : ids.length,
-            });
+            };
+            if (args.combineWithAnd !== false) {
+                qb.andWhere(clause).setParameters(params);
+            } else {
+                qb.orWhere(clause).setParameters(params);
+            }
         } else {
             // If no facetValueIds are specified, no ProductVariants will be matched.
-            qb.andWhere('1 = 0');
+            if (args.combineWithAnd !== false) {
+                qb.andWhere('1 = 0');
+            }
         }
         return qb;
     },
@@ -97,13 +137,13 @@ export const variantNameCollectionFilter = new CollectionFilter({
             },
         },
         term: { type: 'string' },
+        combineWithAnd: combineWithAndArg,
     },
     code: 'variant-name-filter',
     description: [{ languageCode: LanguageCode.en, value: 'Filter by product variant name' }],
     apply: (qb, args) => {
         let translationAlias = `variant_name_filter_translation`;
-        const nanoid = customAlphabet('123456789abcdefghijklmnopqrstuvwxyz', 6);
-        const termName = `term_${nanoid()}`;
+        const termName = randomSuffix(`term`);
         const translationsJoin = qb.expressionMap.joinAttributes.find(
             ja => ja.entityOrProperty === 'productVariant.translations',
         );
@@ -113,26 +153,41 @@ export const variantNameCollectionFilter = new CollectionFilter({
             translationAlias = translationsJoin.alias.name;
         }
         const LIKE = qb.connection.options.type === 'postgres' ? 'ILIKE' : 'LIKE';
+        let clause: string;
+        let params: Record<string, string>;
         switch (args.operator) {
             case 'contains':
-                return qb.andWhere(`${translationAlias}.name ${LIKE} :${termName}`, {
+                clause = `${translationAlias}.name ${LIKE} :${termName}`;
+                params = {
                     [termName]: `%${args.term}%`,
-                });
+                };
+                break;
             case 'doesNotContain':
-                return qb.andWhere(`${translationAlias}.name NOT ${LIKE} :${termName}`, {
+                clause = `${translationAlias}.name NOT ${LIKE} :${termName}`;
+                params = {
                     [termName]: `%${args.term}%`,
-                });
+                };
+                break;
             case 'startsWith':
-                return qb.andWhere(`${translationAlias}.name ${LIKE} :${termName}`, {
+                clause = `${translationAlias}.name ${LIKE} :${termName}`;
+                params = {
                     [termName]: `${args.term}%`,
-                });
+                };
+                break;
             case 'endsWith':
-                return qb.andWhere(`${translationAlias}.name ${LIKE} :${termName}`, {
+                clause = `${translationAlias}.name ${LIKE} :${termName}`;
+                params = {
                     [termName]: `%${args.term}`,
-                });
+                };
+                break;
             default:
                 throw new UserInputError(`${args.operator} is not a valid operator`);
         }
+        if (args.combineWithAnd === false) {
+            return qb.orWhere(clause, params);
+        } else {
+            return qb.andWhere(clause, params);
+        }
     },
 });
 
@@ -141,15 +196,58 @@ export const variantIdCollectionFilter = new CollectionFilter({
         variantIds: {
             type: 'ID',
             list: true,
+            label: [{ languageCode: LanguageCode.en, value: 'Product variants' }],
             ui: {
-                component: 'product-selector-form-input',
+                component: 'product-multi-form-input',
+                selectionMode: 'variant',
             },
         },
+        combineWithAnd: combineWithAndArg,
     },
     code: 'variant-id-filter',
     description: [{ languageCode: LanguageCode.en, value: 'Manually select product variants' }],
     apply: (qb, args) => {
-        return qb.andWhere('productVariant.id IN (:...variantIds)', { variantIds: args.variantIds });
+        if (args.variantIds.length === 0) {
+            return qb;
+        }
+        const variantIdsKey = randomSuffix(`variantIds`);
+        const clause = `productVariant.id IN (:...${variantIdsKey})`;
+        const params = { [variantIdsKey]: args.variantIds };
+        if (args.combineWithAnd === false) {
+            return qb.orWhere(clause, params);
+        } else {
+            return qb.andWhere(clause, params);
+        }
+    },
+});
+
+export const productIdCollectionFilter = new CollectionFilter({
+    args: {
+        productIds: {
+            type: 'ID',
+            list: true,
+            label: [{ languageCode: LanguageCode.en, value: 'Products' }],
+            ui: {
+                component: 'product-multi-form-input',
+                selectionMode: 'product',
+            },
+        },
+        combineWithAnd: combineWithAndArg,
+    },
+    code: 'product-id-filter',
+    description: [{ languageCode: LanguageCode.en, value: 'Manually select products' }],
+    apply: (qb, args) => {
+        if (args.productIds.length === 0) {
+            return qb;
+        }
+        const productIdsKey = randomSuffix(`productIds`);
+        const clause = `productVariant.productId IN (:...${productIdsKey})`;
+        const params = { [productIdsKey]: args.productIds };
+        if (args.combineWithAnd === false) {
+            return qb.orWhere(clause, params);
+        } else {
+            return qb.andWhere(clause, params);
+        }
     },
 });
 
@@ -157,4 +255,5 @@ export const defaultCollectionFilters = [
     facetValueCollectionFilter,
     variantNameCollectionFilter,
     variantIdCollectionFilter,
+    productIdCollectionFilter,
 ];