瀏覽代碼

feat(admin-ui): Filter Asset list by tags

Relates to #316.
Michael Bromley 5 年之前
父節點
當前提交
c244c0a766
共有 28 個文件被更改,包括 284 次插入99 次删除
  1. 13 13
      packages/admin-ui/i18n-coverage.json
  2. 7 8
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.html
  3. 32 18
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.ts
  4. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.html
  5. 1 10
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts
  6. 7 2
      packages/admin-ui/src/lib/core/src/common/base-list.component.ts
  7. 4 2
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  8. 3 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  9. 2 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts
  10. 8 8
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.html
  11. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.scss
  12. 29 32
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts
  13. 35 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.html
  14. 26 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.scss
  15. 92 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.ts
  16. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.html
  17. 6 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.scss
  18. 4 2
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.ts
  19. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  20. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  21. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  22. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  23. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  24. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  25. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  26. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  27. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  28. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

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

@@ -1,49 +1,49 @@
 {
-  "generatedOn": "2021-01-18T19:42:25.183Z",
-  "lastCommit": "cd645caa10aac99dfb3268624888835e8dcf3a2d",
+  "generatedOn": "2021-01-19T09:29:39.498Z",
+  "lastCommit": "c63f9ace215e3a452785cf212f820b7d00933032",
   "translationStatus": {
     "cs": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 755,
       "percentage": 100
     },
     "de": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 596,
       "percentage": 79
     },
     "en": {
-      "tokenCount": 757,
-      "translatedCount": 756,
+      "tokenCount": 758,
+      "translatedCount": 757,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 458,
-      "percentage": 61
+      "percentage": 60
     },
     "fr": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 692,
       "percentage": 91
     },
     "pl": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 551,
       "percentage": 73
     },
     "pt_BR": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 642,
       "percentage": 85
     },
     "zh_Hans": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 533,
       "percentage": 70
     },
     "zh_Hant": {
-      "tokenCount": 757,
+      "tokenCount": 758,
       "translatedCount": 533,
       "percentage": 70
     }

+ 7 - 8
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.html

@@ -1,12 +1,11 @@
 <vdr-action-bar>
-    <vdr-ab-left>
-        <input
-            type="text"
-            name="searchTerm"
-            [formControl]="searchTerm"
-            [placeholder]="'asset.search-asset-name' | translate"
-            class="search-input ml3"
-        />
+    <vdr-ab-left [grow]="true">
+        <vdr-asset-search-input
+            class="pr4 mt1"
+            [tags]="allTags$ | async"
+            (searchTermChange)="searchTerm$.next($event)"
+            (tagsChange)="filterByTags$.next($event)"
+        ></vdr-asset-search-input>
     </vdr-ab-left>
     <vdr-ab-right>
         <vdr-action-bar-items locationId="asset-list"></vdr-action-bar-items>

+ 32 - 18
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.ts

@@ -8,12 +8,14 @@ import {
     DataService,
     DeletionResult,
     GetAssetList,
+    LogicalOperator,
     ModalService,
     NotificationService,
+    TagFragment,
 } from '@vendure/admin-ui/core';
 import { SortOrder } from '@vendure/common/lib/generated-shop-types';
 import { PaginationInstance } from 'ngx-pagination';
-import { combineLatest, EMPTY, Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, EMPTY, Observable } from 'rxjs';
 import { debounceTime, finalize, map, switchMap, takeUntil } from 'rxjs/operators';
 
 @Component({
@@ -22,10 +24,12 @@ import { debounceTime, finalize, map, switchMap, takeUntil } from 'rxjs/operator
     styleUrls: ['./asset-list.component.scss'],
 })
 export class AssetListComponent
-    extends BaseListComponent<GetAssetList.Query, GetAssetList.Items>
+    extends BaseListComponent<GetAssetList.Query, GetAssetList.Items, GetAssetList.Variables>
     implements OnInit {
-    searchTerm = new FormControl('');
+    searchTerm$ = new BehaviorSubject<string | undefined>(undefined);
+    filterByTags$ = new BehaviorSubject<TagFragment[] | undefined>(undefined);
     uploading = false;
+    allTags$: Observable<TagFragment[]>;
     paginationConfig$: Observable<PaginationInstance>;
 
     constructor(
@@ -39,20 +43,29 @@ export class AssetListComponent
         super.setQueryFn(
             (...args: any[]) => this.dataService.product.getAssetList(...args),
             data => data.assets,
-            (skip, take) => ({
-                options: {
-                    skip,
-                    take,
-                    filter: {
-                        name: {
-                            contains: this.searchTerm.value,
+            (skip, take) => {
+                const searchTerm = this.searchTerm$.value;
+                const tags = this.filterByTags$.value?.map(t => t.value);
+                return {
+                    options: {
+                        skip,
+                        take,
+                        ...(searchTerm
+                            ? {
+                                  filter: {
+                                      name: { contains: searchTerm },
+                                  },
+                              }
+                            : {}),
+                        sort: {
+                            createdAt: SortOrder.DESC,
                         },
+                        tags,
+                        tagsOperator: LogicalOperator.AND,
                     },
-                    sort: {
-                        createdAt: SortOrder.DESC,
-                    },
-                },
-            }),
+                };
+            },
+            { take: 25, skip: 0 },
         );
     }
 
@@ -61,9 +74,10 @@ export class AssetListComponent
         this.paginationConfig$ = combineLatest(this.itemsPerPage$, this.currentPage$, this.totalItems$).pipe(
             map(([itemsPerPage, currentPage, totalItems]) => ({ itemsPerPage, currentPage, totalItems })),
         );
-        this.searchTerm.valueChanges
-            .pipe(debounceTime(250), takeUntil(this.destroy$))
-            .subscribe(() => this.refresh());
+        this.searchTerm$.pipe(debounceTime(250), takeUntil(this.destroy$)).subscribe(() => this.refresh());
+
+        this.filterByTags$.pipe(takeUntil(this.destroy$)).subscribe(() => this.refresh());
+        this.allTags$ = this.dataService.product.getTagList().mapStream(data => data.tags.items);
     }
 
     filesSelected(files: File[]) {

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.html

@@ -20,7 +20,7 @@
         </div>
     </ng-template>
     <ng-template ng-label-tmp let-item="item" let-clear="clear">
-        <ng-container *ngIf="item.facetValue">
+        <ng-container *ngIf="item.value">
             <vdr-facet-value-chip
                 [facetValue]="item.facetValue"
                 [removable]="true"

+ 1 - 10
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts

@@ -1,8 +1,7 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
 import { NgSelectComponent, SELECTION_MODEL_FACTORY } from '@ng-select/ng-select';
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-
 import { SearchProducts } from '@vendure/admin-ui/core';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
 import { ProductSearchSelectionModelFactory } from './product-search-selection-model';
 
@@ -69,14 +68,6 @@ export class ProductSearchInputComponent {
         );
     };
 
-    groupByFacet = (item: SearchProducts.FacetValues | { label: string }) => {
-        if (this.isFacetValueItem(item)) {
-            return item.facetValue.facet.name;
-        } else {
-            return '';
-        }
-    };
-
     onSelectChange(selectedItems: Array<SearchProducts.FacetValues | { label: string }>) {
         if (!Array.isArray(selectedItems)) {
             selectedItems = [selectedItems];

+ 7 - 2
packages/admin-ui/src/lib/core/src/common/base-list.component.ts

@@ -28,6 +28,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
     private onPageChangeFn: OnPageChangeFn<VariableType> = (skip, take) =>
         ({ options: { skip, take } } as any);
     private refresh$ = new BehaviorSubject<undefined>(undefined);
+    private defaults: { take: number; skip: number } = { take: 10, skip: 0 };
 
     constructor(protected router: Router, protected route: ActivatedRoute) {}
 
@@ -38,12 +39,16 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
         listQueryFn: ListQueryFn<ResultType>,
         mappingFn: MappingFn<ItemType, ResultType>,
         onPageChangeFn?: OnPageChangeFn<VariableType>,
+        defaults?: { take: number; skip: number },
     ) {
         this.listQueryFn = listQueryFn;
         this.mappingFn = mappingFn;
         if (onPageChangeFn) {
             this.onPageChangeFn = onPageChangeFn;
         }
+        if (defaults) {
+            this.defaults = defaults;
+        }
     }
 
     ngOnInit() {
@@ -52,7 +57,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
                 `No listQueryFn has been defined. Please call super.setQueryFn() in the constructor.`,
             );
         }
-        this.listQuery = this.listQueryFn(10, 0);
+        this.listQuery = this.listQueryFn(this.defaults.take, this.defaults.skip);
 
         const fetchPage = ([currentPage, itemsPerPage, _]: [number, number, undefined]) => {
             const take = itemsPerPage;
@@ -70,7 +75,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
         );
         this.itemsPerPage$ = this.route.queryParamMap.pipe(
             map(qpm => qpm.get('perPage')),
-            map(perPage => (!perPage ? 10 : +perPage)),
+            map(perPage => (!perPage ? this.defaults.take : +perPage)),
             distinctUntilChanged(),
         );
 

+ 4 - 2
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -398,8 +398,10 @@ export const CREATE_ASSETS = gql`
     mutation CreateAssets($input: [CreateAssetInput!]!) {
         createAssets(input: $input) {
             ...Asset
-            tags {
-                ...Tag
+            ... on Asset {
+                tags {
+                    ...Tag
+                }
             }
             ... on ErrorResult {
                 message

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

@@ -95,6 +95,7 @@ export * from './shared/components/asset-gallery/asset-gallery.component';
 export * from './shared/components/asset-picker-dialog/asset-picker-dialog.component';
 export * from './shared/components/asset-preview/asset-preview.component';
 export * from './shared/components/asset-preview-dialog/asset-preview-dialog.component';
+export * from './shared/components/asset-search-input/asset-search-input.component';
 export * from './shared/components/channel-assignment-control/channel-assignment-control.component';
 export * from './shared/components/channel-badge/channel-badge.component';
 export * from './shared/components/chip/chip.component';
@@ -131,6 +132,7 @@ export * from './shared/components/history-entry-detail/history-entry-detail.com
 export * from './shared/components/items-per-page-controls/items-per-page-controls.component';
 export * from './shared/components/labeled-data/labeled-data.component';
 export * from './shared/components/language-selector/language-selector.component';
+export * from './shared/components/manage-tags-dialog/manage-tags-dialog.component';
 export * from './shared/components/modal-dialog/dialog-buttons.directive';
 export * from './shared/components/modal-dialog/dialog-component-outlet.component';
 export * from './shared/components/modal-dialog/dialog-title.directive';
@@ -155,6 +157,7 @@ export * from './shared/components/rich-text-editor/rich-text-editor.component';
 export * from './shared/components/select-toggle/select-toggle.component';
 export * from './shared/components/simple-dialog/simple-dialog.component';
 export * from './shared/components/table-row-action/table-row-action.component';
+export * from './shared/components/tag-selector/tag-selector.component';
 export * from './shared/components/timeline-entry/timeline-entry.component';
 export * from './shared/components/title-input/title-input.component';
 export * from './shared/directives/disabled.directive';

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

@@ -64,6 +64,8 @@ export class AssetGalleryComponent implements OnChanges {
                 this.selection.splice(index, 1);
             }
         }
+        // Make the selection mutable
+        this.selection = this.selection.map(x => ({ ...x }));
         this.selectionChange.emit(this.selection);
     }
 

+ 8 - 8
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.html

@@ -1,6 +1,7 @@
 <ng-template vdrDialogTitle>
     <div class="title-row">
-        {{ 'asset.select-assets' | translate }}
+        <span>{{ 'asset.select-assets' | translate }}</span>
+        <div class="flex-spacer"></div>
         <vdr-asset-file-input
             class="ml3"
             (selectFiles)="createAssets($event)"
@@ -9,13 +10,12 @@
         ></vdr-asset-file-input>
     </div>
 </ng-template>
-<input
-    type="text"
-    name="searchTerm"
-    [formControl]="searchTerm"
-    [placeholder]="'asset.search-asset-name' | translate"
-    class="search-input"
-/>
+<vdr-asset-search-input
+    class="mb2"
+    [tags]="allTags$ | async"
+    (searchTermChange)="searchTerm$.next($event)"
+    (tagsChange)="filterByTags$.next($event)"
+></vdr-asset-search-input>
 <vdr-asset-gallery
     [assets]="(assets$ | async)! | paginate: paginationConfig"
     [multiSelect]="true"

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.scss

@@ -2,7 +2,7 @@
 :host {
     display: flex;
     flex-direction: column;
-    height: 90vh;
+    height: 70vh;
 }
 
 .title-row {

+ 29 - 32
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts

@@ -1,11 +1,16 @@
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
-import { FormControl } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { PaginationInstance } from 'ngx-pagination';
-import { Observable, Subject } from 'rxjs';
+import { BehaviorSubject, Observable, Subject } from 'rxjs';
 import { debounceTime, finalize, map, takeUntil, tap } from 'rxjs/operators';
 
-import { Asset, GetAssetList, SortOrder } from '../../../common/generated-types';
+import {
+    Asset,
+    GetAssetList,
+    LogicalOperator,
+    SortOrder,
+    TagFragment,
+} from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
 import { QueryResult } from '../../../data/query-result';
 import { Dialog } from '../../../providers/modal/modal.service';
@@ -22,6 +27,7 @@ import { NotificationService } from '../../../providers/notification/notificatio
 })
 export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Asset[]> {
     assets$: Observable<GetAssetList.Items[]>;
+    allTags$: Observable<TagFragment[]>;
     paginationConfig: PaginationInstance = {
         currentPage: 1,
         itemsPerPage: 25,
@@ -30,7 +36,8 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
 
     resolveWith: (result?: Asset[]) => void;
     selection: Asset[] = [];
-    searchTerm = new FormControl('');
+    searchTerm$ = new BehaviorSubject<string | undefined>(undefined);
+    filterByTags$ = new BehaviorSubject<TagFragment[] | undefined>(undefined);
     uploading = false;
     private listQuery: QueryResult<GetAssetList.Query, GetAssetList.Variables>;
     private destroy$ = new Subject<void>();
@@ -39,19 +46,17 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
 
     ngOnInit() {
         this.listQuery = this.dataService.product.getAssetList(this.paginationConfig.itemsPerPage, 0);
+        this.allTags$ = this.dataService.product.getTagList().mapSingle(data => data.tags.items);
         this.assets$ = this.listQuery.stream$.pipe(
-            tap((result) => (this.paginationConfig.totalItems = result.assets.totalItems)),
-            map((result) => result.assets.items),
+            tap(result => (this.paginationConfig.totalItems = result.assets.totalItems)),
+            map(result => result.assets.items),
         );
-        this.searchTerm.valueChanges
-            .pipe(debounceTime(250), takeUntil(this.destroy$))
-            .subscribe((searchTerm) => {
-                this.fetchPage(
-                    this.paginationConfig.currentPage,
-                    this.paginationConfig.itemsPerPage,
-                    searchTerm,
-                );
-            });
+        this.searchTerm$.pipe(debounceTime(250), takeUntil(this.destroy$)).subscribe(() => {
+            this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+        });
+        this.filterByTags$.pipe(takeUntil(this.destroy$)).subscribe(() => {
+            this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+        });
     }
 
     ngOnDestroy(): void {
@@ -61,20 +66,12 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
 
     pageChange(page: number) {
         this.paginationConfig.currentPage = page;
-        this.fetchPage(
-            this.paginationConfig.currentPage,
-            this.paginationConfig.itemsPerPage,
-            this.searchTerm.value,
-        );
+        this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
     }
 
     itemsPerPageChange(itemsPerPage: number) {
         this.paginationConfig.itemsPerPage = itemsPerPage;
-        this.fetchPage(
-            this.paginationConfig.currentPage,
-            this.paginationConfig.itemsPerPage,
-            this.searchTerm.value,
-        );
+        this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
     }
 
     cancel() {
@@ -91,12 +88,8 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
             this.dataService.product
                 .createAssets(files)
                 .pipe(finalize(() => (this.uploading = false)))
-                .subscribe((res) => {
-                    this.fetchPage(
-                        this.paginationConfig.currentPage,
-                        this.paginationConfig.itemsPerPage,
-                        this.searchTerm.value,
-                    );
+                .subscribe(res => {
+                    this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
                     this.notificationService.success(_('asset.notify-create-assets-success'), {
                         count: files.length,
                     });
@@ -104,9 +97,11 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
         }
     }
 
-    private fetchPage(currentPage: number, itemsPerPage: number, searchTerm?: string) {
+    private fetchPage(currentPage: number, itemsPerPage: number) {
         const take = +itemsPerPage;
         const skip = (currentPage - 1) * +itemsPerPage;
+        const searchTerm = this.searchTerm$.value;
+        const tags = this.filterByTags$.value?.map(t => t.value);
         this.listQuery.ref.refetch({
             options: {
                 skip,
@@ -119,6 +114,8 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
                 sort: {
                     createdAt: SortOrder.DESC,
                 },
+                tags,
+                tagsOperator: LogicalOperator.AND,
             },
         });
     }

+ 35 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.html

@@ -0,0 +1,35 @@
+<ng-select
+    [addTag]="true"
+    [placeholder]="'catalog.search-asset-name-or-tag' | translate"
+    [items]="tags"
+    [searchFn]="filterTagResults"
+    [hideSelected]="true"
+    [multiple]="true"
+    [markFirst]="false"
+    (change)="onSelectChange($event)"
+    #selectComponent
+>
+    <ng-template ng-header-tmp>
+        <div
+            class="search-header"
+            *ngIf="selectComponent.searchTerm"
+            [class.selected]="isSearchHeaderSelected()"
+            (click)="selectComponent.selectTag()"
+        >
+            {{ 'catalog.search-for-term' | translate }}: {{ selectComponent.searchTerm }}
+        </div>
+    </ng-template>
+    <ng-template ng-label-tmp let-item="item" let-clear="clear">
+        <ng-container *ngIf="item.value">
+            <vdr-chip [colorFrom]="item.value" icon="close" (iconClick)="clear(item)"><clr-icon shape="tag" class="mr2"></clr-icon> {{ item.value }}</vdr-chip>
+        </ng-container>
+        <ng-container *ngIf="!item.value">
+            <vdr-chip [icon]="'times'" (iconClick)="clear(item)">"{{ item.label }}"</vdr-chip>
+        </ng-container>
+    </ng-template>
+    <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
+        <ng-container *ngIf="item.value">
+            <vdr-chip [colorFrom]="item.value"><clr-icon shape="tag" class="mr2"></clr-icon> {{ item.value }}</vdr-chip>
+        </ng-container>
+    </ng-template>
+</ng-select>

+ 26 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.scss

@@ -0,0 +1,26 @@
+@import "mixins";
+
+:host {
+    display: block;
+    width: 100%;
+
+    ::ng-deep {
+        @include ng-select-facet-values;
+    }
+}
+
+ng-select {
+    width: 100%;
+    min-width: 300px;
+    margin-right: 12px;
+}
+
+.search-header {
+    padding: 8px 10px;
+    border-bottom: 1px solid var(--color-component-border-100);
+    cursor: pointer;
+
+    &.selected, &:hover {
+        background-color: var(--color-component-bg-200);
+    }
+}

+ 92 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.ts

@@ -0,0 +1,92 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { NgSelectComponent } from '@ng-select/ng-select';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+
+import { SearchProducts, TagFragment } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-asset-search-input',
+    templateUrl: './asset-search-input.component.html',
+    styleUrls: ['./asset-search-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssetSearchInputComponent {
+    @Input() tags: TagFragment[];
+    @Output() searchTermChange = new EventEmitter<string>();
+    @Output() tagsChange = new EventEmitter<TagFragment[]>();
+    @ViewChild('selectComponent', { static: true }) private selectComponent: NgSelectComponent;
+    private lastTerm = '';
+    private lastTagIds: string[] = [];
+
+    setSearchTerm(term: string | null) {
+        if (term) {
+            this.selectComponent.select({ label: term, value: { label: term } });
+        } else {
+            const currentTerm = this.selectComponent.selectedItems.find(i => !this.isTag(i.value));
+            if (currentTerm) {
+                this.selectComponent.unselect(currentTerm);
+            }
+        }
+    }
+
+    setTags(tags: TagFragment[]) {
+        const items = this.selectComponent.items;
+
+        this.selectComponent.selectedItems.forEach(item => {
+            if (this.isTag(item.value) && !tags.map(t => t.id).includes(item.id)) {
+                this.selectComponent.unselect(item);
+            }
+        });
+
+        tags.map(tag => {
+            return items.find(item => this.isTag(item) && item.id === tag.id);
+        })
+            .filter(notNullOrUndefined)
+            .forEach(item => {
+                const isSelected = this.selectComponent.selectedItems.find(i => {
+                    const val = i.value;
+                    if (this.isTag(val)) {
+                        return val.id === item.id;
+                    }
+                    return false;
+                });
+                if (!isSelected) {
+                    this.selectComponent.select({ label: '', value: item });
+                }
+            });
+    }
+
+    filterTagResults = (term: string, item: SearchProducts.FacetValues | { label: string }) => {
+        if (!this.isTag(item)) {
+            return false;
+        }
+        return item.value.toLowerCase().startsWith(term.toLowerCase());
+    };
+
+    onSelectChange(selectedItems: Array<TagFragment | { label: string }>) {
+        if (!Array.isArray(selectedItems)) {
+            selectedItems = [selectedItems];
+        }
+        const searchTermItem = selectedItems.find(item => !this.isTag(item)) as { label: string } | undefined;
+        const searchTerm = searchTermItem ? searchTermItem.label : '';
+
+        const tags = selectedItems.filter(this.isTag);
+
+        if (searchTerm !== this.lastTerm) {
+            this.searchTermChange.emit(searchTerm);
+            this.lastTerm = searchTerm;
+        }
+        if (this.lastTagIds.join(',') !== tags.map(t => t.id).join(',')) {
+            this.tagsChange.emit(tags);
+            this.lastTagIds = tags.map(t => t.id);
+        }
+    }
+
+    isSearchHeaderSelected(): boolean {
+        return this.selectComponent.itemsList.markedIndex === -1;
+    }
+
+    private isTag = (input: unknown): input is TagFragment => {
+        return typeof input === 'object' && !!input && input.hasOwnProperty('value');
+    };
+}

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.html

@@ -5,11 +5,11 @@
     [clearable]="true"
     [searchable]="true"
     [disabled]="disabled"
+    [placeholder]="placeholder"
     (change)="valueChanged($event)"
 >
     <ng-template ng-label-tmp let-tag="item" let-clear="clear">
-        <span aria-hidden="true" class="ng-value-icon left" (click)="clear(tag)"> × </span>
-        <vdr-chip [colorFrom]="tag"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>
+        <vdr-chip [colorFrom]="tag" icon="close" (iconClick)="clear(tag)"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>
     </ng-template>
     <ng-option *ngFor="let tag of allTags$ | async" [value]="tag">
         <vdr-chip [colorFrom]="tag"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>

+ 6 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.scss

@@ -1,5 +1,11 @@
+@import "mixins";
+
 :host {
     display: block;
     margin-top: 12px;
     position: relative;
+
+    ::ng-deep {
+        @include ng-select-facet-values;
+    }
 }

+ 4 - 2
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.ts

@@ -1,8 +1,9 @@
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { DataService, TagFragment } from '@vendure/admin-ui/core';
 import { Observable } from 'rxjs';
 
+import { DataService } from '../../../data/providers/data.service';
+
 @Component({
     selector: 'vdr-tag-selector',
     templateUrl: './tag-selector.component.html',
@@ -17,6 +18,7 @@ import { Observable } from 'rxjs';
     ],
 })
 export class TagSelectorComponent implements OnInit, ControlValueAccessor {
+    @Input() placeholder: string | undefined;
     allTags$: Observable<string[]>;
     onChange: (val: any) => void;
     onTouch: () => void;

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

@@ -28,6 +28,7 @@ import { AssetGalleryComponent } from './components/asset-gallery/asset-gallery.
 import { AssetPickerDialogComponent } from './components/asset-picker-dialog/asset-picker-dialog.component';
 import { AssetPreviewDialogComponent } from './components/asset-preview-dialog/asset-preview-dialog.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
+import { AssetSearchInputComponent } from './components/asset-search-input/asset-search-input.component';
 import { ChannelAssignmentControlComponent } from './components/channel-assignment-control/channel-assignment-control.component';
 import { ChannelBadgeComponent } from './components/channel-badge/channel-badge.component';
 import { ChipComponent } from './components/chip/chip.component';
@@ -125,6 +126,7 @@ const DECLARATIONS = [
     ActionBarRightComponent,
     AssetPreviewComponent,
     AssetPreviewDialogComponent,
+    AssetSearchInputComponent,
     ConfigurableInputComponent,
     AffixedInputComponent,
     ChipComponent,

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

@@ -129,6 +129,7 @@
     "remove-option": "Odebrat volbu",
     "remove-product-from-channel": "Odebrat produkt z kanálu",
     "remove-product-variant-from-channel": "Odebrat variantu z kanálu",
+    "search-asset-name-or-tag": "",
     "search-for-term": "Hledat výraz",
     "search-product-name-or-code": "Hledat produkt dle jména, nebo kódu",
     "sku": "SKU",

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

@@ -129,6 +129,7 @@
     "remove-option": "Option entfernen",
     "remove-product-from-channel": "Produkt aus dem Kanal entfernen",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "Suche nach Begriff",
     "search-product-name-or-code": "Suche nach Produktname oder -code",
     "sku": "Artikelnummer",

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

@@ -129,6 +129,7 @@
     "remove-option": "Remove option",
     "remove-product-from-channel": "Remove product from channel",
     "remove-product-variant-from-channel": "Remove product variant from channel",
+    "search-asset-name-or-tag": "Search by asset name or tags",
     "search-for-term": "Search for term",
     "search-product-name-or-code": "Search by product name or code",
     "sku": "SKU",

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

@@ -129,6 +129,7 @@
     "remove-option": "Eliminar opción",
     "remove-product-from-channel": "Eliminar product de canal de ventas",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "",
     "search-product-name-or-code": "Buscar por nombre o código de producto",
     "sku": "SKU",

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

@@ -129,6 +129,7 @@
     "remove-option": "Retirer l'option",
     "remove-product-from-channel": "Retirer le produit du canal",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "Chercher le terme",
     "search-product-name-or-code": "Chercher par nom de produit ou code",
     "sku": "UGS",

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

@@ -129,6 +129,7 @@
     "remove-option": "Usuń opcje",
     "remove-product-from-channel": "Usuń produkt z kanału",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "Szukaj frazy",
     "search-product-name-or-code": "Szukaj produktu po nazwie lub kodzie",
     "sku": "SKU",

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

@@ -129,6 +129,7 @@
     "remove-option": "Excluir opção",
     "remove-product-from-channel": "Excluir produto do canal",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "Pesquisar termo",
     "search-product-name-or-code": "Pesquisar por nome ou código do produto",
     "sku": "SKU",

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

@@ -129,6 +129,7 @@
     "remove-option": "移除选项",
     "remove-product-from-channel": "从销售渠道移除商品",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "输入搜索条目",
     "search-product-name-or-code": "输入要搜索的商品名称或商品编码",
     "sku": "商品库存编码",

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

@@ -129,6 +129,7 @@
     "remove-option": "移除選項",
     "remove-product-from-channel": "從渠道移除商品",
     "remove-product-variant-from-channel": "",
+    "search-asset-name-or-tag": "",
     "search-for-term": "輸入搜索條目",
     "search-product-name-or-code": "輸入要搜索的商品名稱或商品編碼",
     "sku": "商品庫存編碼",