Browse Source

feat(admin-ui): Create product asset picker UI components

Needs wiring up to the backend
Michael Bromley 7 years ago
parent
commit
c4dde27dcc
23 changed files with 652 additions and 68 deletions
  1. 7 0
      admin-ui/src/app/catalog/catalog.module.ts
  2. 23 0
      admin-ui/src/app/catalog/components/asset-file-input/asset-file-input.component.html
  3. 44 0
      admin-ui/src/app/catalog/components/asset-file-input/asset-file-input.component.scss
  4. 89 0
      admin-ui/src/app/catalog/components/asset-file-input/asset-file-input.component.ts
  5. 0 1
      admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.scss
  6. 3 1
      admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.ts
  7. 2 8
      admin-ui/src/app/catalog/components/asset-list/asset-list.component.html
  8. 0 4
      admin-ui/src/app/catalog/components/asset-list/asset-list.component.scss
  9. 5 5
      admin-ui/src/app/catalog/components/asset-list/asset-list.component.ts
  10. 33 0
      admin-ui/src/app/catalog/components/asset-picker-dialog/asset-picker-dialog.component.html
  11. 20 0
      admin-ui/src/app/catalog/components/asset-picker-dialog/asset-picker-dialog.component.scss
  12. 78 0
      admin-ui/src/app/catalog/components/asset-picker-dialog/asset-picker-dialog.component.ts
  13. 47 0
      admin-ui/src/app/catalog/components/product-assets/product-assets.component.html
  14. 37 0
      admin-ui/src/app/catalog/components/product-assets/product-assets.component.scss
  15. 67 0
      admin-ui/src/app/catalog/components/product-assets/product-assets.component.ts
  16. 13 4
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  17. 1 0
      admin-ui/src/app/catalog/providers/routing/product-resolver.ts
  18. 18 17
      admin-ui/src/app/data/definitions/product-definitions.ts
  19. 4 0
      admin-ui/src/app/shared/components/modal-dialog/modal-dialog.component.scss
  20. 8 0
      admin-ui/src/i18n-messages/en.json
  21. 103 28
      shared/generated-types.ts
  22. 34 0
      shared/unique.spec.ts
  23. 16 0
      shared/unique.ts

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

@@ -5,14 +5,17 @@ import { SharedModule } from '../shared/shared.module';
 
 import { catalogRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
+import { AssetFileInputComponent } from './components/asset-file-input/asset-file-input.component';
 import { AssetGalleryComponent } from './components/asset-gallery/asset-gallery.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
+import { AssetPickerDialogComponent } from './components/asset-picker-dialog/asset-picker-dialog.component';
 import { CreateOptionGroupDialogComponent } from './components/create-option-group-dialog/create-option-group-dialog.component';
 import { CreateOptionGroupFormComponent } from './components/create-option-group-form/create-option-group-form.component';
 import { FacetDetailComponent } from './components/facet-detail/facet-detail.component';
 import { FacetListComponent } from './components/facet-list/facet-list.component';
 import { FacetValueSelectorComponent } from './components/facet-value-selector/facet-value-selector.component';
 import { GenerateProductVariantsComponent } from './components/generate-product-variants/generate-product-variants.component';
+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 { ProductVariantsListComponent } from './components/product-variants-list/product-variants-list.component';
@@ -41,8 +44,12 @@ import { ProductResolver } from './providers/routing/product-resolver';
         ApplyFacetDialogComponent,
         AssetListComponent,
         AssetGalleryComponent,
+        ProductAssetsComponent,
+        AssetPickerDialogComponent,
+        AssetFileInputComponent,
     ],
     entryComponents: [
+        AssetPickerDialogComponent,
         CreateOptionGroupDialogComponent,
         SelectOptionGroupDialogComponent,
         ApplyFacetDialogComponent,

+ 23 - 0
admin-ui/src/app/catalog/components/asset-file-input/asset-file-input.component.html

@@ -0,0 +1,23 @@
+<input type="file"
+       class="file-input"
+       #fileInput
+       (change)="select($event)" multiple>
+<button class="btn btn-primary" (click)="fileInput.click()">
+    <clr-icon shape="upload-cloud"></clr-icon>
+    {{ 'catalog.upload-assets' | translate }}
+</button>
+<div class="drop-zone"
+     [ngStyle]="dropZoneStyle"
+     [class.visible]="dragging"
+     [class.dragging-over]="overDropZone"
+     (dragenter)="overDropZone = true"
+     (dragleave)="overDropZone = false"
+     (dragover)="onDragOver($event)"
+     (drop)="onDrop($event)"
+     #dropZone>
+    <div class="drop-label"
+         (dragenter)="overDropZone = true">
+        <clr-icon shape="upload-cloud" size="32"></clr-icon>
+        {{ 'catalog.drop-files-to-upload' | translate }}
+    </div>
+</div>

+ 44 - 0
admin-ui/src/app/catalog/components/asset-file-input/asset-file-input.component.scss

@@ -0,0 +1,44 @@
+@import "variables";
+
+.file-input {
+    display: none;
+}
+
+.drop-zone {
+    position: absolute;
+    background-color: transparentize($color-brand, 0.7);
+    border: 3px dashed $color-grey-4;
+    opacity: 0;
+    visibility: hidden;
+    z-index: 1000;
+    transition: opacity 0.2s, background-color 0.2s, visibility 0s 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &.visible {
+        opacity: 1;
+        visibility: visible;
+        transition: opacity 0.2s, background-color 0.2s, border 0.2s, visibility 0s;
+    }
+
+    .drop-label {
+        background-color: rgba(255, 255, 255, 0.8);
+        border-radius: 3px;
+        padding: 24px;
+        font-size: 32px;
+        pointer-events: none;
+        opacity: 0.2;
+        transition: opacity 0.2s;
+    }
+
+    &.dragging-over {
+        border-color: white;
+        background-color: transparentize($color-brand, 0.3);
+        transition: background-color 0.2s, border 0.2s;
+        .drop-label {
+            opacity: 1;
+        }
+    }
+
+}

+ 89 - 0
admin-ui/src/app/catalog/components/asset-file-input/asset-file-input.component.ts

@@ -0,0 +1,89 @@
+import {
+    ChangeDetectionStrategy,
+    Component,
+    EventEmitter,
+    HostListener,
+    Input,
+    OnInit,
+    Output,
+} from '@angular/core';
+import { notNullOrUndefined } from 'shared/shared-utils';
+
+/**
+ * A component for selecting files to upload as new Assets.
+ */
+@Component({
+    selector: 'vdr-asset-file-input',
+    templateUrl: './asset-file-input.component.html',
+    styleUrls: ['./asset-file-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssetFileInputComponent implements OnInit {
+    /**
+     * CSS selector of the DOM element which will be masked by the file
+     * drop zone. Defaults to `body`.
+     */
+    @Input() dropZoneTarget = 'body';
+    @Output() selectFiles = new EventEmitter<File[]>();
+    dragging = false;
+    overDropZone = false;
+    dropZoneStyle = {
+        'width.px': 0,
+        'height.px': 0,
+        'top.px': 0,
+        'left.px': 0,
+    };
+
+    ngOnInit() {
+        this.fitDropZoneToTarget();
+    }
+
+    @HostListener('document:dragenter')
+    onDragEnter() {
+        this.dragging = true;
+        this.fitDropZoneToTarget();
+    }
+
+    @HostListener('document:dragleave', ['$event'])
+    onDragLeave(event: DragEvent) {
+        if (!event.clientX && !event.clientY) {
+            this.dragging = false;
+        }
+    }
+
+    /**
+     * Preventing this event is required to make dropping work.
+     * See https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API#Define_a_drop_zone
+     */
+    onDragOver(event: DragEvent) {
+        event.preventDefault();
+    }
+
+    onDrop(event: DragEvent) {
+        event.preventDefault();
+        this.dragging = false;
+        this.overDropZone = false;
+        const files = Array.from(event.dataTransfer.items)
+            .map(i => i.getAsFile())
+            .filter(notNullOrUndefined);
+        this.selectFiles.emit(files);
+    }
+
+    select(event: Event) {
+        const files = (event.target as HTMLInputElement).files;
+        if (files) {
+            this.selectFiles.emit(Array.from(files));
+        }
+    }
+
+    private fitDropZoneToTarget() {
+        const target = document.querySelector(this.dropZoneTarget) as HTMLElement;
+        if (target) {
+            const rect = target.getBoundingClientRect();
+            this.dropZoneStyle['width.px'] = rect.width;
+            this.dropZoneStyle['height.px'] = rect.height;
+            this.dropZoneStyle['top.px'] = target.offsetTop;
+            this.dropZoneStyle['left.px'] = target.offsetLeft;
+        }
+    }
+}

+ 0 - 1
admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.scss

@@ -10,7 +10,6 @@
     display: grid;
     grid-template-columns: repeat(auto-fill, 150px);
     grid-gap: 10px 20px;
-    height: 100%;
     overflow-y: auto;
     padding-left: 12px;
     padding-top: 12px;

+ 3 - 1
admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
 import { Asset } from 'shared/generated-types';
 
 @Component({
@@ -13,6 +13,7 @@ export class AssetGalleryComponent {
      * If true, allows multiple assets to be selected by ctrl+clicking.
      */
     @Input() multiSelect = false;
+    @Output() selectionChange = new EventEmitter<Asset[]>();
 
     selection: Asset[] = [];
 
@@ -33,6 +34,7 @@ export class AssetGalleryComponent {
                 this.selection.splice(index, 1);
             }
         }
+        this.selectionChange.emit(this.selection);
     }
 
     isSelected(asset: Asset): boolean {

+ 2 - 8
admin-ui/src/app/catalog/components/asset-list/asset-list.component.html

@@ -1,13 +1,7 @@
 <vdr-action-bar>
     <vdr-ab-right>
-        <input type="file"
-               class="file-input"
-               #fileInput
-               (change)="filesSelected($event)" multiple>
-        <button class="btn btn-primary" (click)="fileInput.click()">
-            <clr-icon shape="upload-cloud"></clr-icon>
-            {{ 'catalog.upload-assets' | translate }}
-        </button>
+        <vdr-asset-file-input (selectFiles)="filesSelected($event)"
+                              dropZoneTarget=".content-area"></vdr-asset-file-input>
     </vdr-ab-right>
 </vdr-action-bar>
 

+ 0 - 4
admin-ui/src/app/catalog/components/asset-list/asset-list.component.scss

@@ -6,10 +6,6 @@
     height: 100%;
 }
 
-.file-input {
-    display: none;
-}
-
 vdr-asset-gallery {
     flex: 1;
 }

+ 5 - 5
admin-ui/src/app/catalog/components/asset-list/asset-list.component.ts

@@ -6,6 +6,7 @@ import { map } from 'rxjs/operators';
 import { GetAssetList, GetAssetList_assets_items } from 'shared/generated-types';
 
 import { BaseListComponent } from '../../../common/base-list.component';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';
 
@@ -38,12 +39,11 @@ export class AssetListComponent extends BaseListComponent<GetAssetList, GetAsset
         );
     }
 
-    filesSelected(event: Event) {
-        const files = (event.target as HTMLInputElement).files;
-        if (files) {
-            this.dataService.product.createAssets(Array.from(files)).subscribe(res => {
+    filesSelected(files: File[]) {
+        if (files.length) {
+            this.dataService.product.createAssets(files).subscribe(res => {
                 super.refresh();
-                this.notificationService.success('catalog.notify-create-assets-success', {
+                this.notificationService.success(_('catalog.notify-create-assets-success'), {
                     count: files.length,
                 });
             });

+ 33 - 0
admin-ui/src/app/catalog/components/asset-picker-dialog/asset-picker-dialog.component.html

@@ -0,0 +1,33 @@
+<ng-template vdrDialogTitle>
+    <div class="title-row">
+    {{ 'catalog.select-assets' | translate }}
+    <vdr-asset-file-input (selectFiles)="createAssets($event)"
+                          dropZoneTarget=".modal-content"></vdr-asset-file-input>
+    </div>
+</ng-template>
+
+<vdr-asset-gallery [assets]="assets$ | async | paginate: paginationConfig"
+                   [multiSelect]="true"
+                   (selectionChange)="selection = $event"></vdr-asset-gallery>
+
+<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]="selection.length === 0">
+        {{ 'catalog.add-asset-to-product' | translate: { count: selection.length } }}
+    </button>
+</ng-template>

+ 20 - 0
admin-ui/src/app/catalog/components/asset-picker-dialog/asset-picker-dialog.component.scss

@@ -0,0 +1,20 @@
+@import "variables";
+
+:host {
+    display: flex;
+    flex-direction: column;
+}
+
+.title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}
+
+.paging-controls {
+    padding-top: 6px;
+    border-top: 1px solid $color-grey-2;
+    display: flex;
+    justify-content: space-between;
+    flex-shrink: 0;
+}

+ 78 - 0
admin-ui/src/app/catalog/components/asset-picker-dialog/asset-picker-dialog.component.ts

@@ -0,0 +1,78 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { PaginationInstance } from 'ngx-pagination';
+import { Observable } from 'rxjs';
+import { map, tap } from 'rxjs/operators';
+import { Asset, GetAssetList, GetAssetListVariables } from 'shared/generated-types';
+
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { QueryResult } from '../../../data/query-result';
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+/**
+ * A dialog which allows the creation and selection of assets.
+ */
+@Component({
+    selector: 'vdr-asset-picker-dialog',
+    templateUrl: './asset-picker-dialog.component.html',
+    styleUrls: ['./asset-picker-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssetPickerDialogComponent implements OnInit, Dialog<Asset[]> {
+    assets$: Observable<Asset[]>;
+    paginationConfig: PaginationInstance = {
+        currentPage: 1,
+        itemsPerPage: 25,
+        totalItems: 1,
+    };
+
+    resolveWith: (result?: Asset[]) => void;
+    selection: Asset[] = [];
+    private listQuery: QueryResult<GetAssetList, GetAssetListVariables>;
+
+    constructor(private dataService: DataService, private notificationService: NotificationService) {}
+
+    ngOnInit() {
+        this.listQuery = this.dataService.product.getAssetList(this.paginationConfig.itemsPerPage, 0);
+        this.assets$ = this.listQuery.stream$.pipe(
+            tap(result => (this.paginationConfig.totalItems = result.assets.totalItems)),
+            map(result => result.assets.items),
+        );
+    }
+
+    pageChange(page: number) {
+        this.paginationConfig.currentPage = page;
+        this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+    }
+
+    itemsPerPageChange(itemsPerPage: number) {
+        this.paginationConfig.itemsPerPage = itemsPerPage;
+        this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+
+    select() {
+        this.resolveWith(this.selection);
+    }
+
+    createAssets(files: File[]) {
+        if (files.length) {
+            this.dataService.product.createAssets(files).subscribe(res => {
+                this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+                this.notificationService.success(_('catalog.notify-create-assets-success'), {
+                    count: files.length,
+                });
+            });
+        }
+    }
+
+    private fetchPage(currentPage: number, itemsPerPage: number) {
+        const take = +itemsPerPage;
+        const skip = (currentPage - 1) * +itemsPerPage;
+        this.listQuery.ref.refetch({ options: { skip, take } });
+    }
+}

+ 47 - 0
admin-ui/src/app/catalog/components/product-assets/product-assets.component.html

@@ -0,0 +1,47 @@
+<div class="card">
+    <div class="card-img">
+        <div class="featured-asset">
+            <img *ngIf="featuredAsset"
+                 [src]="featuredAsset!.preview + '?preset=small'">
+            <div class="placeholder" *ngIf="!featuredAsset">
+                <clr-icon shape="image"
+                          size="128"></clr-icon>
+                <div>{{ 'catalog.no-featured-asset' | translate }}</div>
+            </div>
+        </div>
+    </div>
+    <div class="card-block">
+        <div class="all-assets">
+            <ng-container *ngFor="let asset of assets">
+                <clr-dropdown>
+                    <div class="asset-thumb"
+                         clrDropdownTrigger
+                         [class.featured]="isFeatured(asset)"
+                         [title]=""
+                         tabindex="0">
+                        <img [src]="asset.preview + '?preset=tiny'">
+                    </div>
+                    <clr-dropdown-menu *clrIfOpen
+                                       clrPosition="bottom-right">
+                        <button type="button"
+                                [disabled]="isFeatured(asset)"
+                                clrDropdownItem
+                                (click)="setAsFeatured(asset)">{{ 'catalog.set-as-featured-asset' | translate }}</button>
+                        <div class="dropdown-divider"></div>
+                        <button type="button"
+                                class="remove-asset"
+                                clrDropdownItem
+                                (click)="removeAsset(asset)">{{ 'catalog.remove-asset' | translate }}</button>
+                    </clr-dropdown-menu>
+                </clr-dropdown>
+
+            </ng-container>
+        </div>
+    </div>
+    <div class="card-footer">
+        <button class="btn" (click)="selectAssets()">
+            <clr-icon shape="attachment"></clr-icon>
+            {{ 'catalog.add-asset' | translate }}
+        </button>
+    </div>
+</div>

+ 37 - 0
admin-ui/src/app/catalog/components/product-assets/product-assets.component.scss

@@ -0,0 +1,37 @@
+@import "variables";
+
+:host {
+    width: 340px;
+    display: block;
+}
+
+.placeholder {
+    text-align: center;
+    color: $color-grey-3;
+}
+
+.featured-asset {
+    text-align: center;
+    background: $color-grey-2;
+    padding: 6px;
+}
+
+.all-assets {
+    display: flex;
+    flex-wrap: wrap;
+
+    .asset-thumb {
+        margin: 3px;
+        padding: 0;
+        border: 2px solid $color-grey-2;
+        cursor: pointer;
+
+        &.featured {
+            border-color: $color-brand;
+        }
+    }
+
+    .remove-asset {
+        color: $color-warning;
+    }
+}

+ 67 - 0
admin-ui/src/app/catalog/components/product-assets/product-assets.component.ts

@@ -0,0 +1,67 @@
+import {
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    EventEmitter,
+    Input,
+    Output,
+} from '@angular/core';
+import { Asset } from 'shared/generated-types';
+import { unique } from 'shared/unique';
+
+import { ModalService } from '../../../shared/providers/modal/modal.service';
+import { AssetPickerDialogComponent } from '../asset-picker-dialog/asset-picker-dialog.component';
+
+/**
+ * A component which displays the Assets associated with a product, and allows assets to be removed and
+ * added, and for the featured asset to be set.
+ */
+@Component({
+    selector: 'vdr-product-assets',
+    templateUrl: './product-assets.component.html',
+    styleUrls: ['./product-assets.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductAssetsComponent {
+    @Input() assets: Asset[] = [];
+    @Input() featuredAsset: Asset | undefined;
+    @Output() change = new EventEmitter<{ assetIds: string[]; featuredAssetId: string }>();
+
+    constructor(private modalService: ModalService, private changeDetector: ChangeDetectorRef) {}
+
+    nonFeaturedAssets(): Asset[] {
+        const featuredAssetId = this.featuredAsset && this.featuredAsset.id;
+        return this.assets.filter(a => a.id !== featuredAssetId);
+    }
+
+    selectAssets() {
+        this.modalService
+            .fromComponent(AssetPickerDialogComponent, {
+                size: 'xl',
+            })
+            .subscribe(result => {
+                if (result && result.length) {
+                    this.assets = unique(this.assets.concat(result), 'id');
+                    if (!this.featuredAsset) {
+                        this.featuredAsset = result[0];
+                    }
+                    this.changeDetector.markForCheck();
+                }
+            });
+    }
+
+    setAsFeatured(asset: Asset) {
+        this.featuredAsset = asset;
+    }
+
+    isFeatured(asset: Asset): boolean {
+        return !!this.featuredAsset && this.featuredAsset.id === asset.id;
+    }
+
+    removeAsset(asset: Asset) {
+        this.assets = this.assets.filter(a => a.id !== asset.id);
+        if (this.featuredAsset && this.featuredAsset.id === asset.id) {
+            this.featuredAsset = this.assets.length > 0 ? this.assets[0] : undefined;
+        }
+    }
+}

+ 13 - 4
admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -19,7 +19,10 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<form class="form" [formGroup]="productForm" >
+<form class="form" [formGroup]="productForm" *ngIf="product$ | async as product">
+
+    <div class="clr-row">
+        <div class="clr-col">
     <section class="form-block" formGroupName="product">
         <label>{{ 'catalog.product' | translate }}</label>
         <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
@@ -41,19 +44,25 @@
             </ng-container>
         </section>
     </section>
+        </div>
+        <div class="clr-col-md-auto">
+            <vdr-product-assets [assets]="product.assets"
+                                [featuredAsset]="product.featuredAsset"></vdr-product-assets>
+        </div>
+    </div>
 
     <section class="form-block" *ngIf="!(isNew$ | async)">
 
         <label>{{ 'catalog.product-variants' | translate }}</label>
 
         <vdr-generate-product-variants *ngIf="(variants$ | async)?.length === 0; else variants"
-                                       [product]="product$ | async"></vdr-generate-product-variants>
+                                       [product]="product"></vdr-generate-product-variants>
 
         <ng-template #variants>
-            <vdr-form-item *ngIf="(product$ | async)?.optionGroups.length"
+            <vdr-form-item *ngIf="product?.optionGroups.length"
                            [label]="'catalog.product-option-groups' | translate">
                 <div class="option-groups-list">
-                    <div *ngFor="let optionGroup of (product$ | async)?.optionGroups"
+                    <div *ngFor="let optionGroup of product?.optionGroups"
                          class="option-group">
                         <vdr-chip>{{ optionGroup.name }} ({{ optionGroup.code }})</vdr-chip>
                     </div>

+ 1 - 0
admin-ui/src/app/catalog/providers/routing/product-resolver.ts

@@ -20,6 +20,7 @@ export class ProductResolver extends BaseEntityResolver<ProductWithVariants> {
                 name: '',
                 slug: '',
                 image: '',
+                featuredAsset: null,
                 assets: [],
                 description: '',
                 translations: [],

+ 18 - 17
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -1,5 +1,18 @@
 import gql from 'graphql-tag';
 
+export const ASSET_FRAGMENT = gql`
+    fragment Asset on Asset {
+        id
+        name
+        fileSize
+        mimeType
+        type
+        name
+        preview
+        source
+    }
+`;
+
 export const PRODUCT_VARIANT_FRAGMENT = gql`
     fragment ProductVariant on ProductVariant {
         id
@@ -35,11 +48,11 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         slug
         image
         description
+        featuredAsset {
+            ...Asset
+        }
         assets {
-            description
-            name
-            preview
-            type
+            ...Asset
         }
         translations {
             languageCode
@@ -58,6 +71,7 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         }
     }
     ${PRODUCT_VARIANT_FRAGMENT}
+    ${ASSET_FRAGMENT}
 `;
 
 export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
@@ -81,19 +95,6 @@ export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
     }
 `;
 
-export const ASSET_FRAGMENT = gql`
-    fragment Asset on Asset {
-        id
-        name
-        fileSize
-        mimeType
-        type
-        name
-        preview
-        source
-    }
-`;
-
 export const UPDATE_PRODUCT = gql`
     mutation UpdateProduct($input: UpdateProductInput!) {
         updateProduct(input: $input) {

+ 4 - 0
admin-ui/src/app/shared/components/modal-dialog/modal-dialog.component.scss

@@ -0,0 +1,4 @@
+.modal-body {
+    display: flex;
+    flex-direction: column;
+}

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

@@ -29,6 +29,8 @@
     "roles": "Roles"
   },
   "catalog": {
+    "add-asset": "Add asset",
+    "add-asset-to-product": "Add {count, plural, 0 {assets} one {1 asset} other {{count} assets}} to product",
     "add-facet-value": "Add facet value",
     "apply-facets": "Apply facets",
     "assets-selected-count": "{ count } assets selected",
@@ -38,6 +40,7 @@
     "create-new-option-group": "Create new option group",
     "create-new-product": "Create new product",
     "description": "Description",
+    "drop-files-to-upload": "Drop files to upload",
     "facet": "Facet",
     "facet-values": "Facet values",
     "facets": "Facets",
@@ -46,7 +49,9 @@
     "generate-variants-default-only": "This product does not have options",
     "generate-variants-with-options": "This product has options",
     "name": "Name",
+    "no-featured-asset": "No featured asset",
     "no-selection": "No selection",
+    "notify-create-assets-success": "Created {count, plural, one {new Asset} other {{count} new Assets}}",
     "open-asset-source": "Open asset source",
     "option-group-code": "Code",
     "option-group-name": "Option group name",
@@ -59,8 +64,11 @@
     "product-name": "Product name",
     "product-option-groups": "Option groups",
     "product-variants": "Product variants",
+    "remove-asset": "Remove asset",
+    "select-assets": "Select assets",
     "select-option-group": "Select option group",
     "selected-option-groups": "Selected option groups",
+    "set-as-featured-asset": "Set as featured asset",
     "sku": "SKU",
     "slug": "Slug",
     "truncated-options-count": "{count} further {count, plural, one {option} other {options}}",

+ 103 - 28
shared/generated-types.ts

@@ -803,12 +803,26 @@ export interface GetUiState {
 // GraphQL mutation operation: UpdateProduct
 // ====================================================
 
-export interface UpdateProduct_updateProduct_assets {
+export interface UpdateProduct_updateProduct_featuredAsset {
   __typename: "Asset";
-  description: string | null;
+  id: string;
   name: string;
+  fileSize: number;
+  mimeType: string;
+  type: AssetType;
   preview: string;
+  source: string;
+}
+
+export interface UpdateProduct_updateProduct_assets {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  fileSize: number;
+  mimeType: string;
   type: AssetType;
+  preview: string;
+  source: string;
 }
 
 export interface UpdateProduct_updateProduct_translations {
@@ -870,6 +884,7 @@ export interface UpdateProduct_updateProduct {
   slug: string;
   image: string;
   description: string;
+  featuredAsset: UpdateProduct_updateProduct_featuredAsset | null;
   assets: UpdateProduct_updateProduct_assets[];
   translations: UpdateProduct_updateProduct_translations[];
   optionGroups: UpdateProduct_updateProduct_optionGroups[];
@@ -894,12 +909,26 @@ export interface UpdateProductVariables {
 // GraphQL mutation operation: CreateProduct
 // ====================================================
 
-export interface CreateProduct_createProduct_assets {
+export interface CreateProduct_createProduct_featuredAsset {
   __typename: "Asset";
-  description: string | null;
+  id: string;
   name: string;
+  fileSize: number;
+  mimeType: string;
+  type: AssetType;
   preview: string;
+  source: string;
+}
+
+export interface CreateProduct_createProduct_assets {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  fileSize: number;
+  mimeType: string;
   type: AssetType;
+  preview: string;
+  source: string;
 }
 
 export interface CreateProduct_createProduct_translations {
@@ -961,6 +990,7 @@ export interface CreateProduct_createProduct {
   slug: string;
   image: string;
   description: string;
+  featuredAsset: CreateProduct_createProduct_featuredAsset | null;
   assets: CreateProduct_createProduct_assets[];
   translations: CreateProduct_createProduct_translations[];
   optionGroups: CreateProduct_createProduct_optionGroups[];
@@ -985,12 +1015,26 @@ export interface CreateProductVariables {
 // GraphQL mutation operation: GenerateProductVariants
 // ====================================================
 
-export interface GenerateProductVariants_generateVariantsForProduct_assets {
+export interface GenerateProductVariants_generateVariantsForProduct_featuredAsset {
   __typename: "Asset";
-  description: string | null;
+  id: string;
   name: string;
+  fileSize: number;
+  mimeType: string;
+  type: AssetType;
   preview: string;
+  source: string;
+}
+
+export interface GenerateProductVariants_generateVariantsForProduct_assets {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  fileSize: number;
+  mimeType: string;
   type: AssetType;
+  preview: string;
+  source: string;
 }
 
 export interface GenerateProductVariants_generateVariantsForProduct_translations {
@@ -1052,6 +1096,7 @@ export interface GenerateProductVariants_generateVariantsForProduct {
   slug: string;
   image: string;
   description: string;
+  featuredAsset: GenerateProductVariants_generateVariantsForProduct_featuredAsset | null;
   assets: GenerateProductVariants_generateVariantsForProduct_assets[];
   translations: GenerateProductVariants_generateVariantsForProduct_translations[];
   optionGroups: GenerateProductVariants_generateVariantsForProduct_optionGroups[];
@@ -1308,12 +1353,26 @@ export interface ApplyFacetValuesToProductVariantsVariables {
 // GraphQL query operation: GetProductWithVariants
 // ====================================================
 
-export interface GetProductWithVariants_product_assets {
+export interface GetProductWithVariants_product_featuredAsset {
   __typename: "Asset";
-  description: string | null;
+  id: string;
   name: string;
+  fileSize: number;
+  mimeType: string;
+  type: AssetType;
   preview: string;
+  source: string;
+}
+
+export interface GetProductWithVariants_product_assets {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  fileSize: number;
+  mimeType: string;
   type: AssetType;
+  preview: string;
+  source: string;
 }
 
 export interface GetProductWithVariants_product_translations {
@@ -1375,6 +1434,7 @@ export interface GetProductWithVariants_product {
   slug: string;
   image: string;
   description: string;
+  featuredAsset: GetProductWithVariants_product_featuredAsset | null;
   assets: GetProductWithVariants_product_assets[];
   translations: GetProductWithVariants_product_translations[];
   optionGroups: GetProductWithVariants_product_optionGroups[];
@@ -1650,6 +1710,24 @@ export interface FacetWithValues {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL fragment: Asset
+// ====================================================
+
+export interface Asset {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  fileSize: number;
+  mimeType: string;
+  type: AssetType;
+  preview: string;
+  source: string;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL fragment: ProductVariant
 // ====================================================
@@ -1696,12 +1774,26 @@ export interface ProductVariant {
 // GraphQL fragment: ProductWithVariants
 // ====================================================
 
-export interface ProductWithVariants_assets {
+export interface ProductWithVariants_featuredAsset {
   __typename: "Asset";
-  description: string | null;
+  id: string;
   name: string;
+  fileSize: number;
+  mimeType: string;
+  type: AssetType;
   preview: string;
+  source: string;
+}
+
+export interface ProductWithVariants_assets {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  fileSize: number;
+  mimeType: string;
   type: AssetType;
+  preview: string;
+  source: string;
 }
 
 export interface ProductWithVariants_translations {
@@ -1763,6 +1855,7 @@ export interface ProductWithVariants {
   slug: string;
   image: string;
   description: string;
+  featuredAsset: ProductWithVariants_featuredAsset | null;
   assets: ProductWithVariants_assets[];
   translations: ProductWithVariants_translations[];
   optionGroups: ProductWithVariants_optionGroups[];
@@ -1808,24 +1901,6 @@ export interface ProductOptionGroup {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
-// ====================================================
-// GraphQL fragment: Asset
-// ====================================================
-
-export interface Asset {
-  __typename: "Asset";
-  id: string;
-  name: string;
-  fileSize: number;
-  mimeType: string;
-  type: AssetType;
-  preview: string;
-  source: string;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
 //==============================================================
 // START Enums and Input Objects
 //==============================================================

+ 34 - 0
shared/unique.spec.ts

@@ -0,0 +1,34 @@
+import { unique } from './unique';
+
+describe('unique()', () => {
+
+    it('works with primitive values', () => {
+        expect(unique([1, 1, 2, 3, 2, 6, 4, 2])).toEqual([1, 2, 3, 6, 4]);
+        expect(unique(['a', 'f', 'g', 'f', 'y'])).toEqual(['a', 'f', 'g', 'y']);
+        expect(unique([null, null, 1, 'a', 1])).toEqual([null, 1, 'a']);
+    });
+
+    it('works with object references', () => {
+        const a = { a: true };
+        const b = { b: true };
+        const c = { c: true };
+
+        expect(unique([a, b, a, b, c, a])).toEqual([a, b, c]);
+        expect(unique([a, b, a, b, c, a])[0]).toBe(a);
+        expect(unique([a, b, a, b, c, a])[1]).toBe(b);
+        expect(unique([a, b, a, b, c, a])[2]).toBe(c);
+    });
+
+    it('works with object key param', () => {
+        const a = { id: 'a', a: true };
+        const b = { id: 'b', b: true };
+        const c = { id: 'c', c: true };
+        const d = { id: 'a', d: true };
+
+        expect(unique([a, b, a, b, d, c, a], 'id')).toEqual([a, b, c]);
+    });
+
+    it('works an empty array', () => {
+        expect(unique([])).toEqual([]);
+    });
+});

+ 16 - 0
shared/unique.ts

@@ -0,0 +1,16 @@
+/**
+ * Returns an array with only unique values. Objects are compared by reference,
+ * unless the `byKey` argument is supplied, in which case matching properties will
+ * be used to check duplicates
+ */
+export function unique<T>(arr: T[], byKey?: keyof T): T[] {
+    return arr.filter((item, index, self) => {
+        return index === self.findIndex(i => {
+            if (byKey === undefined) {
+                return i === item;
+            } else {
+                return i[byKey] === item[byKey];
+            }
+        });
+    });
+}