Browse Source

feat(admin-ui): Implement product selector custom form input

Relates to #400
Michael Bromley 5 years ago
parent
commit
f687f49632

+ 52 - 2
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -3015,6 +3015,8 @@ export type Query = {
   product?: Maybe<Product>;
   productOptionGroup?: Maybe<ProductOptionGroup>;
   productOptionGroups: Array<ProductOptionGroup>;
+  /** Get a ProductVariant by id */
+  productVariant?: Maybe<ProductVariant>;
   products: ProductList;
   promotion?: Maybe<Promotion>;
   promotionActions: Array<ConfigurableOperationDefinition>;
@@ -3167,6 +3169,11 @@ export type QueryProductOptionGroupsArgs = {
 };
 
 
+export type QueryProductVariantArgs = {
+  id: Scalars['ID'];
+};
+
+
 export type QueryProductsArgs = {
   options?: Maybe<ProductListOptions>;
 };
@@ -5505,7 +5512,14 @@ export type ProductSelectorSearchQuery = (
     & { items: Array<(
       { __typename?: 'SearchResult' }
       & Pick<SearchResult, 'productVariantId' | 'productVariantName' | 'productPreview' | 'sku'>
-      & { price: { __typename?: 'PriceRange' } | (
+      & { productAsset?: Maybe<(
+        { __typename?: 'SearchResultAsset' }
+        & Pick<SearchResultAsset, 'id' | 'preview'>
+        & { focalPoint?: Maybe<(
+          { __typename?: 'Coordinate' }
+          & Pick<Coordinate, 'x' | 'y'>
+        )> }
+      )>, price: { __typename?: 'PriceRange' } | (
         { __typename?: 'SinglePrice' }
         & Pick<SinglePrice, 'value'>
       ), priceWithTax: { __typename?: 'PriceRange' } | (
@@ -5604,6 +5618,31 @@ export type RemoveProductsFromChannelMutation = (
   )> }
 );
 
+export type GetProductVariantQueryVariables = {
+  id: Scalars['ID'];
+};
+
+
+export type GetProductVariantQuery = (
+  { __typename?: 'Query' }
+  & { productVariant?: Maybe<(
+    { __typename?: 'ProductVariant' }
+    & Pick<ProductVariant, 'id' | 'name' | 'sku'>
+    & { product: (
+      { __typename?: 'Product' }
+      & Pick<Product, 'id'>
+      & { featuredAsset?: Maybe<(
+        { __typename?: 'Asset' }
+        & Pick<Asset, 'id' | 'preview'>
+        & { focalPoint?: Maybe<(
+          { __typename?: 'Coordinate' }
+          & Pick<Coordinate, 'x' | 'y'>
+        )> }
+      )> }
+    ) }
+  )> }
+);
+
 export type PromotionFragment = (
   { __typename?: 'Promotion' }
   & Pick<Promotion, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled' | 'couponCode' | 'perCustomerUsageLimit' | 'startsAt' | 'endsAt'>
@@ -6722,7 +6761,7 @@ export type ConfigurableOperationDefFragment = (
   & Pick<ConfigurableOperationDefinition, 'code' | 'description'>
   & { args: Array<(
     { __typename?: 'ConfigArgDefinition' }
-    & Pick<ConfigArgDefinition, 'name' | 'type' | 'list' | 'ui'>
+    & Pick<ConfigArgDefinition, 'name' | 'type' | 'list' | 'ui' | 'label'>
   )> }
 );
 
@@ -7581,6 +7620,8 @@ export namespace ProductSelectorSearch {
   export type Query = ProductSelectorSearchQuery;
   export type Search = ProductSelectorSearchQuery['search'];
   export type Items = (NonNullable<ProductSelectorSearchQuery['search']['items'][0]>);
+  export type ProductAsset = (NonNullable<(NonNullable<ProductSelectorSearchQuery['search']['items'][0]>)['productAsset']>);
+  export type FocalPoint = (NonNullable<(NonNullable<(NonNullable<ProductSelectorSearchQuery['search']['items'][0]>)['productAsset']>)['focalPoint']>);
   export type Price = (NonNullable<ProductSelectorSearchQuery['search']['items'][0]>)['price'];
   export type SinglePriceInlineFragment = (DiscriminateUnion<RequireField<(NonNullable<ProductSelectorSearchQuery['search']['items'][0]>)['price'], '__typename'>, { __typename: 'SinglePrice' }>);
   export type PriceWithTax = (NonNullable<ProductSelectorSearchQuery['search']['items'][0]>)['priceWithTax'];
@@ -7623,6 +7664,15 @@ export namespace RemoveProductsFromChannel {
   export type Channels = (NonNullable<(NonNullable<RemoveProductsFromChannelMutation['removeProductsFromChannel'][0]>)['channels'][0]>);
 }
 
+export namespace GetProductVariant {
+  export type Variables = GetProductVariantQueryVariables;
+  export type Query = GetProductVariantQuery;
+  export type ProductVariant = (NonNullable<GetProductVariantQuery['productVariant']>);
+  export type Product = (NonNullable<GetProductVariantQuery['productVariant']>)['product'];
+  export type FeaturedAsset = (NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']['featuredAsset']>);
+  export type FocalPoint = (NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']['featuredAsset']>)['focalPoint']>);
+}
+
 export namespace Promotion {
   export type Fragment = PromotionFragment;
   export type Conditions = ConfigurableOperationFragment;

+ 29 - 0
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -449,6 +449,14 @@ export const PRODUCT_SELECTOR_SEARCH = gql`
                 productVariantId
                 productVariantName
                 productPreview
+                productAsset {
+                    id
+                    preview
+                    focalPoint {
+                        x
+                        y
+                    }
+                }
                 price {
                     ... on SinglePrice {
                         value
@@ -545,3 +553,24 @@ export const REMOVE_PRODUCTS_FROM_CHANNEL = gql`
         }
     }
 `;
+
+export const GET_PRODUCT_VARIANT = gql`
+    query GetProductVariant($id: ID!) {
+        productVariant(id: $id) {
+            id
+            name
+            sku
+            product {
+                id
+                featuredAsset {
+                    id
+                    preview
+                    focalPoint {
+                        x
+                        y
+                    }
+                }
+            }
+        }
+    }
+`;

+ 1 - 0
packages/admin-ui/src/lib/core/src/data/definitions/shared-definitions.ts

@@ -17,6 +17,7 @@ export const CONFIGURABLE_OPERATION_DEF_FRAGMENT = gql`
             type
             list
             ui
+            label
         }
         code
         description

+ 12 - 5
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -21,8 +21,10 @@ import {
     GetProductList,
     GetProductOptionGroup,
     GetProductOptionGroups,
+    GetProductVariant,
     GetProductVariantOptions,
     GetProductWithVariants,
+    ProductListOptions,
     ProductSelectorSearch,
     Reindex,
     RemoveOptionGroupFromProduct,
@@ -55,6 +57,7 @@ import {
     GET_PRODUCT_LIST,
     GET_PRODUCT_OPTION_GROUP,
     GET_PRODUCT_OPTION_GROUPS,
+    GET_PRODUCT_VARIANT,
     GET_PRODUCT_VARIANT_OPTIONS,
     GET_PRODUCT_WITH_VARIANTS,
     PRODUCT_SELECTOR_SEARCH,
@@ -98,12 +101,9 @@ export class ProductDataService {
         return this.baseDataService.mutate<Reindex.Mutation>(REINDEX);
     }
 
-    getProducts(take: number = 10, skip: number = 0) {
+    getProducts(options: ProductListOptions) {
         return this.baseDataService.query<GetProductList.Query, GetProductList.Variables>(GET_PRODUCT_LIST, {
-            options: {
-                take,
-                skip,
-            },
+            options,
         });
     }
 
@@ -116,6 +116,13 @@ export class ProductDataService {
         );
     }
 
+    getProductVariant(id: string) {
+        return this.baseDataService.query<GetProductVariant.Query, GetProductVariant.Variables>(
+            GET_PRODUCT_VARIANT,
+            { id },
+        );
+    }
+
     getProductVariantsOptions(id: string) {
         return this.baseDataService.query<GetProductVariantOptions.Query, GetProductVariantOptions.Variables>(
             GET_PRODUCT_VARIANT_OPTIONS,

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

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

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

@@ -1,7 +1,6 @@
 <ng-select
     #autoComplete
     [items]="searchResults$ | async"
-    bindLabel="productVariantName"
     [addTag]="false"
     [multiple]="false"
     [hideSelected]="true"
@@ -9,4 +8,10 @@
     [typeahead]="searchInput$"
     [placeholder]="'settings.search-by-product-name-or-sku' | translate"
     (change)="selectResult($event)"
-></ng-select>
+>
+    <ng-template ng-option-tmp let-item="item">
+        <img [src]="item.productAsset | assetPreview: 32">
+        {{ item.productVariantName }}
+        <small class="sku">{{ item.sku }}</small>
+    </ng-template>
+</ng-select>

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

@@ -0,0 +1,6 @@
+@import "variables";
+
+.sku {
+    margin-left: 12px;
+    color: $color-grey-500;
+}

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

@@ -0,0 +1,20 @@
+<ul class="list-unstyled">
+    <li *ngFor="let variant of selection$ | async" class="variant">
+        <div class="thumb">
+            <img [src]="variant.product.featuredAsset | assetPreview: 32" />
+        </div>
+        <div class="detail">
+            <div>{{ variant.name }}</div>
+            <div class="sku">{{ variant.sku }}</div>
+        </div>
+        <div class="flex-spacer"></div>
+        <button
+            class="btn btn-link btn-sm btn-warning"
+            (click)="removeProductVariant(variant.id)"
+            [title]="'common.remove-item-from-list' | translate"
+        >
+            <clr-icon shape="times"></clr-icon>
+        </button>
+    </li>
+</ul>
+<vdr-product-selector (productSelected)="addProductVariant($event)"></vdr-product-selector>

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

@@ -0,0 +1,19 @@
+@import "variables";
+
+.variant {
+    margin-bottom: 6px;
+    display: flex;
+    align-items: center;
+    transition: background-color 0.2s;
+    &:hover {
+        background-color: $color-grey-200;
+    }
+}
+.thumb {
+    margin-right: 6px;
+}
+.sku {
+    color: $color-grey-400;
+    font-size: smaller;
+    line-height: 1em;
+}

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

@@ -0,0 +1,67 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { forkJoin, Observable, of } from 'rxjs';
+import { map, startWith, switchMap } from 'rxjs/operators';
+
+import { FormInputComponent, InputComponentConfig } from '../../../common/component-registry-types';
+import { GetProductVariant, ProductSelectorSearch } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+
+@Component({
+    selector: 'vdr-product-selector-form-input',
+    templateUrl: './product-selector-form-input.component.html',
+    styleUrls: ['./product-selector-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductSelectorFormInputComponent implements FormInputComponent, OnInit {
+    static readonly id: DefaultFormComponentId = 'product-selector-form-input';
+    readonly isListInput = true;
+    readonly: boolean;
+    formControl: FormControl;
+    config: InputComponentConfig;
+    selection$: Observable<GetProductVariant.ProductVariant[]>;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit() {
+        this.formControl.setValidators([
+            control => {
+                if (!control.value || !control.value.length) {
+                    return {
+                        atLeastOne: { length: control.value.length },
+                    };
+                }
+                return null;
+            },
+        ]);
+
+        this.selection$ = this.formControl.valueChanges.pipe(
+            startWith(this.formControl.value),
+            switchMap(value => {
+                if (Array.isArray(value)) {
+                    return forkJoin(
+                        value.map(id =>
+                            this.dataService.product
+                                .getProductVariant(id)
+                                .mapSingle(data => data.productVariant),
+                        ),
+                    );
+                }
+                return of([]);
+            }),
+            map(variants => variants.filter(notNullOrUndefined)),
+        );
+    }
+
+    addProductVariant(product: ProductSelectorSearch.Items) {
+        const value = this.formControl.value as string[];
+        this.formControl.setValue([...new Set([...value, product.productVariantId])]);
+    }
+
+    removeProductVariant(id: string) {
+        const value = this.formControl.value as string[];
+        this.formControl.setValue(value.filter(_id => _id !== id));
+    }
+}

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

@@ -13,6 +13,7 @@ import { CurrencyFormInputComponent } from './currency-form-input/currency-form-
 import { DateFormInputComponent } from './date-form-input/date-form-input.component';
 import { FacetValueFormInputComponent } from './facet-value-form-input/facet-value-form-input.component';
 import { NumberFormInputComponent } from './number-form-input/number-form-input.component';
+import { ProductSelectorFormInputComponent } from './product-selector-form-input/product-selector-form-input.component';
 import { SelectFormInputComponent } from './select-form-input/select-form-input.component';
 import { TextFormInputComponent } from './text-form-input/text-form-input.component';
 
@@ -24,6 +25,7 @@ export const defaultFormInputs = [
     NumberFormInputComponent,
     SelectFormInputComponent,
     TextFormInputComponent,
+    ProductSelectorFormInputComponent,
 ];
 
 /**

+ 6 - 2
packages/admin-ui/src/lib/core/src/shared/pipes/asset-preview.pipe.ts

@@ -6,11 +6,15 @@ import { Asset } from '../../common/generated-types';
     name: 'assetPreview',
 })
 export class AssetPreviewPipe implements PipeTransform {
-    transform(asset: Asset, preset: string = 'thumb'): string {
+    transform(asset: Asset, preset: string | number = 'thumb'): string {
         if (!asset.preview || typeof asset.preview !== 'string') {
             throw new Error(`Expected an Asset, got ${JSON.stringify(asset)}`);
         }
         const fp = asset.focalPoint ? `&fpx=${asset.focalPoint.x}&fpy=${asset.focalPoint.y}` : '';
-        return `${asset.preview}?preset=${preset}${fp}`;
+        if (Number.isNaN(Number(preset))) {
+            return `${asset.preview}?preset=${preset}${fp}`;
+        } else {
+            return `${asset.preview}?w=${preset}&h=${preset}${fp}`;
+        }
     }
 }

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

@@ -82,6 +82,7 @@ import { DateFormInputComponent } from './dynamic-form-inputs/date-form-input/da
 import { DynamicFormInputComponent } from './dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component';
 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 { ProductSelectorFormInputComponent } from './dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component';
 import { SelectFormInputComponent } from './dynamic-form-inputs/select-form-input/select-form-input.component';
 import { TextFormInputComponent } from './dynamic-form-inputs/text-form-input/text-form-input.component';
 import { AssetPreviewPipe } from './pipes/asset-preview.pipe';
@@ -182,6 +183,7 @@ const DECLARATIONS = [
     TimelineEntryComponent,
     HistoryEntryDetailComponent,
     EditNoteDialogComponent,
+    ProductSelectorFormInputComponent,
     OrderStateI18nTokenPipe,
     ProductSelectorComponent,
 ];