Răsfoiți Sursa

feat(admin-ui): Implement FormInput for multi product/variant selection

Michael Bromley 3 ani în urmă
părinte
comite
47c9b0ead7
33 a modificat fișierele cu 512 adăugiri și 27 ștergeri
  1. 7 7
      packages/admin-ui/scripts/extract-translations.js
  2. 0 2
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  3. 1 2
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts
  4. 0 1
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  5. 10 4
      packages/admin-ui/src/lib/core/src/common/utilities/selection-manager.ts
  6. 4 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  7. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts
  8. 92 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.html
  9. 76 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.scss
  10. 167 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.ts
  11. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.html
  12. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.scss
  13. 3 1
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.ts
  14. 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
  15. 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
  16. 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
  17. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  18. 11 8
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts
  19. 6 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  20. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  21. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  22. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  23. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  24. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  25. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  26. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  27. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  28. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  29. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  30. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  31. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  32. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  33. 7 1
      packages/common/src/shared-types.ts

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

@@ -9,7 +9,7 @@ extractTranslations().then(
     () => {
         process.exit(0);
     },
-    (error) => {
+    error => {
         console.log(error);
         process.exit(1);
     },
@@ -19,12 +19,13 @@ extractTranslations().then(
  * Extracts translation tokens into the i18n-messages files found in the MESSAGES_DIR.
  */
 async function extractTranslations() {
-    const locales = fs.readdirSync(MESSAGES_DIR).map((file) => path.basename(file).replace('.json', ''));
+    const locales = fs.readdirSync(MESSAGES_DIR).map(file => path.basename(file).replace('.json', ''));
     const report = {
         generatedOn: new Date().toISOString(),
         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,11 +54,11 @@ function runExtraction(locale) {
     const args = getNgxTranslateExtractCommand(locale);
     return new Promise((resolve, reject) => {
         try {
-            const child = spawn(`yarnpkg`, args, { stdio: ['pipe', 'pipe', process.stderr] });
-            child.on('close', (x) => {
+            const child = spawn(`yarnpkg`, args, { stdio: ['inherit', 'inherit', 'inherit'] });
+            child.on('close', x => {
                 resolve();
             });
-            child.on('error', (err) => {
+            child.on('error', err => {
                 reject(err);
             });
         } catch (e) {
@@ -74,7 +74,7 @@ function getStatsForLocale(locale) {
     for (const section of Object.keys(content)) {
         const sectionTranslations = Object.values(content[section]);
         tokenCount += sectionTranslations.length;
-        translatedCount += sectionTranslations.filter((val) => val !== '').length;
+        translatedCount += sectionTranslations.filter(val => val !== '').length;
     }
     const percentage = Math.round((translatedCount / tokenCount) * 100);
     return {

+ 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,

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

@@ -10,6 +10,7 @@ import {
     LogicalOperator,
     ModalService,
     NotificationService,
+    ProductSearchInputComponent,
     SearchInput,
     SearchProducts,
     ServerConfigService,
@@ -27,8 +28,6 @@ import {
     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

@@ -20,7 +20,6 @@ export * from './components/option-value-input/option-value-input.component';
 export * from './components/product-detail/product-detail.component';
 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';

+ 10 - 4
packages/admin-ui/src/lib/core/src/common/utilities/selection-manager.ts

@@ -1,10 +1,16 @@
+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: { multiSelect: boolean; itemsAreEqual: (a: T, b: T) => boolean }) {}
+    constructor(private options: SelectionManagerOptions<T>) {}
 
     get selection(): T[] {
         return this._selection;
@@ -22,7 +28,7 @@ export class SelectionManager<T> {
     }
 
     toggleSelection(item: T, event?: MouseEvent) {
-        const { multiSelect, itemsAreEqual } = this.options;
+        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];
@@ -34,7 +40,7 @@ export class SelectionManager<T> {
                 ...this.items.slice(start, end).filter(a => !this._selection.find(s => itemsAreEqual(a, s))),
             );
         } else if (index === -1) {
-            if (multiSelect && (event?.ctrlKey || event?.shiftKey)) {
+            if (multiSelect && (event?.ctrlKey || event?.shiftKey || additiveMode)) {
                 this._selection.push(item);
             } else {
                 this._selection = [item];
@@ -42,7 +48,7 @@ export class SelectionManager<T> {
         } else {
             if (multiSelect && event?.ctrlKey) {
                 this._selection.splice(index, 1);
-            } else if (1 < this._selection.length) {
+            } else if (1 < this._selection.length && !additiveMode) {
                 this._selection = [item];
             } else {
                 this._selection.splice(index, 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';
@@ -145,6 +146,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-selector/product-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';
@@ -182,6 +185,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';

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

@@ -34,6 +34,7 @@ export class AssetGalleryComponent implements OnChanges {
     selectionManager = new SelectionManager<AssetLike>({
         multiSelect: this.multiSelect,
         itemsAreEqual: (a, b) => a.id === b.id,
+        additiveMode: false,
     });
 
     constructor(private modalService: ModalService) {}

+ 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 { SearchProducts, SingleSearchSelectionModelFactory } from '@vendure/admin-ui/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
+import { SearchProducts } from '../../../common/generated-types';
+import { SingleSearchSelectionModelFactory } from '../../../common/single-search-selection-model';
+
 @Component({
     selector: 'vdr-product-search-input',
     templateUrl: './product-search-input.component.html',

+ 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();
+                }
+            });
+    }
+}

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

@@ -16,6 +16,7 @@ import { DateFormInputComponent } from './date-form-input/date-form-input.compon
 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 +39,7 @@ export const defaultFormInputs = [
     TextareaFormInputComponent,
     RichTextFormInputComponent,
     JsonEditorFormInputComponent,
+    ProductMultiSelectorFormInputComponent,
 ];
 
 /**

+ 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 { GetAsset, 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<GetAsset.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: GetAsset.Asset) {

+ 6 - 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 { ProductSelectorComponent } from './components/product-selector/product-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';
@@ -95,6 +97,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 +237,9 @@ const DECLARATIONS = [
     UiExtensionPointComponent,
     CustomDetailComponentHostComponent,
     AssetPreviewLinksComponent,
+    ProductMultiSelectorDialogComponent,
+    ProductMultiSelectorFormInputComponent,
+    ProductSearchInputComponent
 ];
 
 const DYNAMIC_FORM_INPUTS = [

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

@@ -178,6 +178,7 @@
     "change-selection": "",
     "channel": "Kanál",
     "channels": "Kanály",
+    "clear-selection": "",
     "close": "",
     "code": "Kód",
     "collapse-entries": "Schovat vstupy",
@@ -205,6 +206,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",
@@ -240,8 +242,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": "",

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

@@ -178,6 +178,7 @@
     "change-selection": "Auswahl ändern",
     "channel": "Kanal",
     "channels": "Kanäle",
+    "clear-selection": "",
     "close": "",
     "code": "Code",
     "collapse-entries": "Einträge einklappen",
@@ -205,6 +206,7 @@
     "general": "",
     "guest": "Gast",
     "items-per-page-option": "{ count } pro Seite",
+    "items-selected-count": "",
     "language": "Sprache",
     "launch-extension": "Erweiterung starten",
     "live-update": "Live-Aktualisierung",
@@ -240,8 +242,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",

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -178,6 +178,7 @@
     "change-selection": "Change selection",
     "channel": "Channel",
     "channels": "Channels",
+    "clear-selection": "Clear selection",
     "close": "Close",
     "code": "Code",
     "collapse-entries": "Collapse entries",
@@ -205,6 +206,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",
@@ -240,8 +242,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",
@@ -672,4 +677,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

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

@@ -178,6 +178,7 @@
     "change-selection": "Cambiar selección",
     "channel": "Canal de ventas",
     "channels": "Canales de ventas",
+    "clear-selection": "",
     "close": "Cerrar",
     "code": "Código",
     "collapse-entries": "Ocultar entradas",
@@ -205,6 +206,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",
@@ -240,8 +242,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",

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

@@ -178,6 +178,7 @@
     "change-selection": "Modifier la sélection",
     "channel": "Canal",
     "channels": "Canaux",
+    "clear-selection": "",
     "close": "",
     "code": "Code",
     "collapse-entries": "Réduire les éléments",
@@ -205,6 +206,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",
@@ -240,8 +242,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",

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

@@ -178,6 +178,7 @@
     "change-selection": "Cambia selezione",
     "channel": "Canale",
     "channels": "Canali",
+    "clear-selection": "",
     "close": "Chiudi",
     "code": "Codice",
     "collapse-entries": "Riduci elementi",
@@ -205,6 +206,7 @@
     "general": "",
     "guest": "Ospite",
     "items-per-page-option": "{ count } per pagina",
+    "items-selected-count": "",
     "language": "Lingua",
     "launch-extension": "Lancia estensione",
     "live-update": "Aggiornamenti live",
@@ -240,8 +242,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",

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

@@ -178,6 +178,7 @@
     "change-selection": "",
     "channel": "Kanał",
     "channels": "Kanały",
+    "clear-selection": "",
     "close": "",
     "code": "Kod",
     "collapse-entries": "",
@@ -205,6 +206,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",
@@ -240,8 +242,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": "",

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

@@ -178,6 +178,7 @@
     "change-selection": "",
     "channel": "Canal",
     "channels": "Canais",
+    "clear-selection": "",
     "close": "",
     "code": "Código",
     "collapse-entries": "Recolher entradas",
@@ -205,6 +206,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",
@@ -240,8 +242,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": "",

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

@@ -178,6 +178,7 @@
     "change-selection": "Alterar seleccionados",
     "channel": "Canal",
     "channels": "Canais",
+    "clear-selection": "",
     "close": "Fechar",
     "code": "Código",
     "collapse-entries": "Recolher entradas",
@@ -205,6 +206,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",
@@ -240,8 +242,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",

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

@@ -178,6 +178,7 @@
     "change-selection": "Изменить выбор",
     "channel": "Канал",
     "channels": "Каналы",
+    "clear-selection": "",
     "close": "Закрыть",
     "code": "Код",
     "collapse-entries": "Свернуть записи",
@@ -205,6 +206,7 @@
     "general": "",
     "guest": "Гость",
     "items-per-page-option": "{ count } на странице",
+    "items-selected-count": "",
     "language": "Язык",
     "launch-extension": "Запуск расширения",
     "live-update": "Обновление в режиме реального времени",
@@ -240,8 +242,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": "Теги",

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

@@ -178,6 +178,7 @@
     "change-selection": "Змінити вибір",
     "channel": "Канал",
     "channels": "Канали",
+    "clear-selection": "",
     "close": "Закрити",
     "code": "Код",
     "collapse-entries": "Згорнути записи",
@@ -205,6 +206,7 @@
     "general": "",
     "guest": "Гість",
     "items-per-page-option": "{ count } на сторінці",
+    "items-selected-count": "",
     "language": "Мова",
     "launch-extension": "Запуск розширення",
     "live-update": "Оновлення в режимі реального часу",
@@ -240,8 +242,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": "Теги",

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

@@ -178,6 +178,7 @@
     "change-selection": "更改选项",
     "channel": "销售渠道",
     "channels": "销售渠道",
+    "clear-selection": "",
     "close": "",
     "code": "编码",
     "collapse-entries": "收起",
@@ -205,6 +206,7 @@
     "general": "",
     "guest": "游客",
     "items-per-page-option": "每页显示 { count } 条",
+    "items-selected-count": "",
     "language": "语言",
     "launch-extension": "启动扩展插件",
     "live-update": "在线更新",
@@ -240,8 +242,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": "标签",

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

@@ -178,6 +178,7 @@
     "change-selection": "",
     "channel": "渠道",
     "channels": "渠道",
+    "clear-selection": "",
     "close": "",
     "code": "編碼",
     "collapse-entries": "",
@@ -205,6 +206,7 @@
     "general": "",
     "guest": "游客",
     "items-per-page-option": "每页顯示 { count } 條",
+    "items-selected-count": "",
     "language": "語言",
     "launch-extension": "启動扩展插件",
     "live-update": "",
@@ -240,8 +242,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": "",

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

@@ -143,7 +143,9 @@ 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';
 
 /**
  * @description
@@ -170,6 +172,10 @@ type DefaultFormConfigHash = {
     'textarea-form-input': {
         spellcheck?: boolean;
     };
+    'asset-form-input': {};
+    'product-multi-form-input': {
+        selectionMode?: 'product' | 'variant';
+    };
 };
 
 export type DefaultFormComponentUiConfig<T extends DefaultFormComponentId | string> =