Browse Source

feat(admin-ui): Create ProductSearchInput bar

Michael Bromley 6 years ago
parent
commit
0668443e49

+ 1 - 1
admin-ui/package.json

@@ -25,7 +25,7 @@
     "@clr/angular": "^1.1.3",
     "@clr/icons": "^1.1.3",
     "@clr/ui": "^1.1.3",
-    "@ng-select/ng-select": "^2.15.1",
+    "@ng-select/ng-select": "^2.19.0",
     "@ngx-translate/core": "^11.0.1",
     "@ngx-translate/http-loader": "^4.0.0",
     "@webcomponents/custom-elements": "^1.2.1",

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

@@ -24,6 +24,7 @@ import { GenerateProductVariantsComponent } from './components/generate-product-
 import { ProductAssetsComponent } from './components/product-assets/product-assets.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
+import { ProductSearchInputComponent } from './components/product-search-input/product-search-input.component';
 import { ProductVariantsListComponent } from './components/product-variants-list/product-variants-list.component';
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
 import { ProductVariantsWizardComponent } from './components/product-variants-wizard/product-variants-wizard.component';
@@ -63,6 +64,7 @@ import { ProductResolver } from './providers/routing/product-resolver';
         CollectionContentsComponent,
         ProductVariantsTableComponent,
         AssetPreviewComponent,
+        ProductSearchInputComponent,
     ],
     entryComponents: [
         AssetPickerDialogComponent,

+ 39 - 0
admin-ui/src/app/catalog/components/product-search-input/product-search-input.component.html

@@ -0,0 +1,39 @@
+<ng-select
+    [addTag]="true"
+    [placeholder]="'catalog.search-product-name-or-code' | translate"
+    [items]="facetValueResults"
+    [searchFn]="filterFacetResults"
+    [hideSelected]="true"
+    [multiple]="true"
+    [markFirst]="false"
+    (change)="onSelectChange($event)"
+    #selectComponent
+>
+    <ng-template ng-header-tmp>
+        <div
+            class="search-header"
+            *ngIf="selectComponent.filterValue"
+            [class.selected]="isSearchHeaderSelected()"
+            (click)="selectComponent.selectTag()"
+        >
+            {{ 'catalog.search-for-term' | translate }}: {{ selectComponent.filterValue }}
+        </div>
+    </ng-template>
+    <ng-template ng-label-tmp let-item="item" let-clear="clear">
+        <ng-container *ngIf="item.facetValue">
+            <vdr-facet-value-chip
+                [facetValue]="item.facetValue"
+                [removable]="true"
+                (remove)="clear(item)"
+            ></vdr-facet-value-chip>
+        </ng-container>
+        <ng-container *ngIf="!item.facetValue">
+            <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.facetValue">
+            <vdr-facet-value-chip [facetValue]="item.facetValue" [removable]="false"></vdr-facet-value-chip>
+        </ng-container>
+    </ng-template>
+</ng-select>

+ 49 - 0
admin-ui/src/app/catalog/components/product-search-input/product-search-input.component.scss

@@ -0,0 +1,49 @@
+@import "variables";
+
+:host {
+    margin-top: 6px;
+    display: block;
+    width: 100%;
+    margin-right: 24px;
+
+    ::ng-deep {
+
+        .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-value {
+            background: none;
+            margin: 0;
+        }
+
+        .ng-dropdown-panel-items div.ng-option:last-child {
+            display: none;
+        }
+
+        .ng-dropdown-panel .ng-dropdown-header {
+            border: none;
+            padding: 0;
+        }
+
+        .ng-select.ng-select-multiple .ng-select-container .ng-value-container {
+            padding: 0;
+        }
+
+        .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder {
+            padding-left: 8px;
+        }
+    }
+}
+
+ng-select {
+    width: 100%;
+    min-width: 300px;
+    margin-right: 12px;
+}
+
+.search-header {
+    padding: 8px 10px;
+    border-bottom: 1px solid $color-grey-200;
+    cursor: pointer;
+
+    &.selected, &:hover {
+        background-color: $color-grey-200;
+    }
+}

+ 58 - 0
admin-ui/src/app/catalog/components/product-search-input/product-search-input.component.ts

@@ -0,0 +1,58 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { NgSelectComponent, SELECTION_MODEL_FACTORY } from '@ng-select/ng-select';
+
+import { SearchProducts } from '../../../common/generated-types';
+
+import { ProductSearchSelectionModelFactory } from './product-search-selection-model';
+
+@Component({
+    selector: 'vdr-product-search-input',
+    templateUrl: './product-search-input.component.html',
+    styleUrls: ['./product-search-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [{ provide: SELECTION_MODEL_FACTORY, useValue: ProductSearchSelectionModelFactory }],
+})
+export class ProductSearchInputComponent {
+    @Input() facetValueResults: SearchProducts.FacetValues[];
+    @Output() searchTermChange = new EventEmitter<string>();
+    @Output() facetValueChange = new EventEmitter<string[]>();
+    @ViewChild('selectComponent') private selectComponent: NgSelectComponent;
+
+    filterFacetResults = (term: string, item: SearchProducts.FacetValues | { label: string }) => {
+        if (!this.isFacetValueItem(item)) {
+            return false;
+        }
+        return (
+            item.facetValue.name.toLowerCase().startsWith(term.toLowerCase()) ||
+            item.facetValue.facet.name.toLowerCase().startsWith(term.toLowerCase())
+        );
+    };
+
+    groupByFacet = (item: SearchProducts.FacetValues | { label: string }) => {
+        if (this.isFacetValueItem(item)) {
+            return item.facetValue.facet.name;
+        } else {
+            return '';
+        }
+    };
+
+    onSelectChange(selectedItems: Array<SearchProducts.FacetValues | { label: string }>) {
+        const searchTermItem = selectedItems.find(item => !this.isFacetValueItem(item)) as
+            | { label: string }
+            | undefined;
+        const searchTerm = searchTermItem ? searchTermItem.label : '';
+
+        const facetValueIds = selectedItems.filter(this.isFacetValueItem).map(i => i.facetValue.id);
+
+        this.searchTermChange.emit(searchTerm);
+        this.facetValueChange.emit(facetValueIds);
+    }
+
+    isSearchHeaderSelected(): boolean {
+        return this.selectComponent.itemsList.markedIndex === -1;
+    }
+
+    private isFacetValueItem = (input: unknown): input is SearchProducts.FacetValues => {
+        return typeof input === 'object' && !!input && input.hasOwnProperty('facetValue');
+    };
+}

+ 56 - 0
admin-ui/src/app/catalog/components/product-search-input/product-search-selection-model.ts

@@ -0,0 +1,56 @@
+import { NgOption, SelectionModel } from '@ng-select/ng-select';
+
+/**
+ * A custom SelectionModel for the NgSelect component which only allows a single
+ * search term at a time.
+ */
+export class ProductSearchSelectionModel implements SelectionModel {
+    private _selected: NgOption[] = [];
+
+    get value(): NgOption[] {
+        return this._selected;
+    }
+
+    select(item: NgOption, multiple: boolean, groupAsModel: boolean) {
+        item.selected = true;
+        if (groupAsModel || !item.children) {
+            if ((item.value as any).label) {
+                const isSearchTerm = (i: any) => !!i.value.label;
+                const searchTerms = this._selected.filter(isSearchTerm);
+                if (searchTerms.length > 0) {
+                    // there is already a search term, so replace it with this new one.
+                    this._selected = this._selected.filter(i => !isSearchTerm(i)).concat(item);
+                } else {
+                    this._selected.push(item);
+                }
+            } else {
+                this._selected.push(item);
+            }
+        }
+    }
+
+    unselect(item: NgOption, multiple: boolean) {
+        this._selected = this._selected.filter(x => x !== item);
+        item.selected = false;
+    }
+
+    clear(keepDisabled: boolean) {
+        this._selected = keepDisabled ? this._selected.filter(x => x.disabled) : [];
+    }
+
+    private _setChildrenSelectedState(children: NgOption[], selected: boolean) {
+        children.forEach(x => (x.selected = selected));
+    }
+
+    private _removeChildren(parent: NgOption) {
+        this._selected = this._selected.filter(x => x.parent !== parent);
+    }
+
+    private _removeParent(parent: NgOption) {
+        this._selected = this._selected.filter(x => x !== parent);
+    }
+}
+
+export function ProductSearchSelectionModelFactory() {
+    return new ProductSearchSelectionModel();
+}

+ 4 - 0
admin-ui/src/styles/theme/_forms.scss

@@ -56,4 +56,8 @@ select {
 .ng-select.ng-select-focused>.ng-select-container {
     border-color: $color-primary-500 !important;
     box-shadow: 0 0 1px 1px $color-primary-100;
+    border-radius: 3px;
+}
+.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
+    background-color: $color-grey-200;
 }

+ 4 - 4
admin-ui/yarn.lock

@@ -354,10 +354,10 @@
   resolved "https://registry.yarnpkg.com/@clr/ui/-/ui-1.1.3.tgz#b736ead16ced97938370090694cc4da64601b2f8"
   integrity sha512-0H3/6YeVltmS3a0cNG8TyOB7nRhpHjVtzTSC06hmk2/cp4x14XPHdHabV1gwWq1ZXfGb5cLDRBb2cjWb546sKQ==
 
-"@ng-select/ng-select@^2.15.1":
-  version "2.15.1"
-  resolved "https://registry.yarnpkg.com/@ng-select/ng-select/-/ng-select-2.15.1.tgz#c28e18ed798e59d369658c89187a49ee915df706"
-  integrity sha512-MzRfCUbHMbnX6fLP7Pw1qoufGmu3sLPrfmu8/J/p1pYIQySV1DgXlC3/Yo20jq4rkY0WmOquEqTY/5fKUEs4/A==
+"@ng-select/ng-select@^2.19.0":
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/@ng-select/ng-select/-/ng-select-2.19.0.tgz#6e56eef5bed92fc8380dbb1cea2f4c2dce15f733"
+  integrity sha512-xyQjPNv9m9tWA5hpNLlDQQagIHv6EDFkoTjtu3OjIn/9HE9kMB32OQ5yDgXP2hDB6NoBoP/vhTAPzRumvmwA/A==
   dependencies:
     tslib "^1.9.0"