Browse Source

feat(admin-ui): Implement creation of ProductVariants via wizard

Michael Bromley 7 years ago
parent
commit
b9b37692f9
20 changed files with 310 additions and 153 deletions
  1. 4 0
      admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.ts
  2. 6 23
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  3. 1 17
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.scss
  4. 8 75
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  5. 40 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.html
  6. 0 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.scss
  7. 41 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts
  8. 97 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.ts
  9. 1 1
      admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.html
  10. 5 4
      admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.ts
  11. 21 8
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.html
  12. 11 11
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.scss
  13. 4 4
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts
  14. 35 9
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.ts
  15. 3 0
      admin-ui/src/app/core/components/notification/notification.component.scss
  16. 1 0
      admin-ui/src/app/data/fragments/product-fragments.ts
  17. 2 0
      admin-ui/src/app/data/types/gql-generated-types.ts
  18. 1 0
      admin-ui/src/app/shared/components/chip/chip.component.scss
  19. 2 0
      admin-ui/src/app/shared/shared.module.ts
  20. 27 1
      admin-ui/src/i18n-messages/en.json

+ 4 - 0
admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.ts

@@ -33,6 +33,10 @@ export class CreateOptionGroupFormComponent implements OnInit {
         });
     }
 
+    resetForm() {
+        this.optionGroupForm.reset();
+    }
+
     updateCode(nameValue: string) {
         const codeControl = this.optionGroupForm.get('code');
         if (codeControl && codeControl.pristine) {

+ 6 - 23
admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -59,11 +59,14 @@
                     <button type="button"
                             clrDropdownItem
                             (click)="generateProductVariants()">{{ 'catalog.generate-variants-default-only' | translate }}</button>
-                   <!-- <button type="button"
+                    <button type="button"
                             clrDropdownItem
-                            (click)="createNewOptionGroup()">{{ 'catalog.generate-variants-with-options' | translate }}</button>-->
+                            (click)="startProductVariantsWizard()">{{ 'catalog.generate-variants-with-options' | translate }}</button>
                 </clr-dropdown-menu>
             </clr-dropdown>
+
+            <vdr-product-variants-wizard #productVariantsWizard
+                                         [product]="product$ | async"></vdr-product-variants-wizard>
         </div>
 
         <ng-template #variants>
@@ -71,28 +74,8 @@
                 <div class="option-groups-list">
                     <div *ngFor="let optionGroup of (product$ | async)?.optionGroups"
                          class="option-group">
-                        <div class="group-name">{{ optionGroup.name }}</div>
-                        <button type="button"
-                                class="btn remove-option-group"
-                                (click)="removeGroup(optionGroup.id)">
-                            <clr-icon shape="trash"></clr-icon>
-                        </button>
+                        <vdr-chip>{{ optionGroup.name }} ({{ optionGroup.code }})</vdr-chip>
                     </div>
-                    <clr-dropdown>
-                        <button type="button" class="btn btn-outline-primary btn-sm" clrDropdownTrigger>
-                            <clr-icon shape="add"></clr-icon>
-                            {{ 'catalog.add-option-group' | translate }}
-                            <clr-icon shape="caret down"></clr-icon>
-                        </button>
-                        <clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
-                            <button type="button"
-                                    clrDropdownItem
-                                    (click)="addExistingOptionGroup()">{{ 'catalog.add-existing-option-group' | translate }}</button>
-                            <button type="button"
-                                    clrDropdownItem
-                                    (click)="createNewOptionGroup()">{{ 'catalog.create-new-option-group' | translate }}</button>
-                        </clr-dropdown-menu>
-                    </clr-dropdown>
                 </div>
             </vdr-form-item>
 

+ 1 - 17
admin-ui/src/app/catalog/components/product-detail/product-detail.component.scss

@@ -3,26 +3,10 @@
 .option-groups-list {
 
 }
-.option-group {
-    border: 1px solid $color-grey-4;
-    border-radius: 3px;
-    display: inline-block;
-    padding: 0 6px;
-    margin-right: 6px;
-    display: inline-flex;
-}
+
 .group-name {
     padding-right: 6px;
 }
-.remove-option-group {
-    margin: 0 !important;
-    padding: 0;
-    min-width: 0;
-    height: 22px;
-    line-height: 1rem;
-    border: none;
-    color: $color-warning;
-}
 
 .variants-list {
 

+ 8 - 75
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -107,78 +107,6 @@ export class ProductDetailComponent implements OnDestroy {
         this.setQueryParam('lang', code);
     }
 
-    createNewOptionGroup() {
-        this.product$
-            .pipe(
-                take(1),
-                mergeMap(product => {
-                    const productFormGroup = this.productForm.get('product');
-                    const productNameControl = productFormGroup && productFormGroup.get('name');
-                    const productName = productNameControl ? productNameControl.value : '';
-                    return this.modalService
-                        .fromComponent(CreateOptionGroupDialogComponent, {
-                            closable: true,
-                            size: 'lg',
-                            locals: {
-                                productName,
-                                productId: product.id,
-                            },
-                        })
-                        .pipe(
-                            filter(notNullOrUndefined),
-                            mergeMap(({ createProductOptionGroup }) => {
-                                return this.dataService.product.addOptionGroupToProduct({
-                                    productId: product.id,
-                                    optionGroupId: createProductOptionGroup.id,
-                                });
-                            }),
-                        );
-                }),
-            )
-            .subscribe();
-    }
-
-    addExistingOptionGroup() {
-        this.product$
-            .pipe(
-                take(1),
-                mergeMap(product => {
-                    return this.modalService
-                        .fromComponent(SelectOptionGroupDialogComponent, {
-                            closable: true,
-                            size: 'lg',
-                            locals: {
-                                existingOptionGroupIds: product.optionGroups.map(g => g.id),
-                            },
-                        })
-                        .pipe(
-                            filter(notNullOrUndefined),
-                            mergeMap(optionGroup => {
-                                return this.dataService.product.addOptionGroupToProduct({
-                                    productId: product.id,
-                                    optionGroupId: optionGroup.id,
-                                });
-                            }),
-                        );
-                }),
-            )
-            .subscribe();
-    }
-
-    removeGroup(optionGroupId: string) {
-        this.product$
-            .pipe(
-                take(1),
-                mergeMap(product => {
-                    return this.dataService.product.removeOptionGroupFromProduct({
-                        productId: product.id,
-                        optionGroupId,
-                    });
-                }),
-            )
-            .subscribe();
-    }
-
     create() {
         const productGroup = this.productForm.get('product');
         if (!productGroup || !productGroup.dirty) {
@@ -251,9 +179,14 @@ export class ProductDetailComponent implements OnDestroy {
     }
 
     startProductVariantsWizard() {
-        this.product$.pipe(mergeMap(product => this.productVariantsWizard.start(product))).subscribe(val => {
-            // console.log('finished', val);
-        });
+        this.product$
+            .pipe(
+                take(1),
+                mergeMap(product => this.productVariantsWizard.start()),
+            )
+            .subscribe(() => {
+                this.generateProductVariants();
+            });
     }
 
     generateProductVariants() {

+ 40 - 0
admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.html

@@ -0,0 +1,40 @@
+<clr-wizard #wizard clrWizardSize="xl">
+    <clr-wizard-title>{{ 'catalog.generate-product-variants' | translate }}</clr-wizard-title>
+
+    <clr-wizard-button type="cancel">{{ 'common.cancel' | translate }}</clr-wizard-button>
+    <clr-wizard-button type="previous">{{ 'common.back' | translate }}</clr-wizard-button>
+    <clr-wizard-button type="next">{{ 'common.next' | translate }}</clr-wizard-button>
+    <clr-wizard-button type="finish">{{ 'common.finish' | translate }}</clr-wizard-button>
+
+    <clr-wizard-page>
+        <ng-template clrPageTitle>{{ 'catalog.create-new-option-group' | translate }}</ng-template>
+        <vdr-create-option-group-form #createOptionGroupForm
+                                      [productName]="product?.name"
+                                      [productId]="product?.id"></vdr-create-option-group-form>
+        <button (click)="createOptionGroup()"
+                class="btn btn-primary"
+                [disabled]="createOptionGroupForm.optionGroupForm.invalid || createOptionGroupForm.optionGroupForm.pristine">
+            {{ 'catalog.create-group' | translate }}
+        </button>
+    </clr-wizard-page>
+
+    <clr-wizard-page (clrWizardPageOnLoad)="selectOptionGroup.refresh()"
+                     [clrWizardPageNextDisabled]="selectedOptionGroups.length < 1">
+        <ng-template clrPageTitle>{{ 'catalog.select-option-group' | translate }}</ng-template>
+        <vdr-select-option-group #selectOptionGroup
+                                 [selectedGroups]="selectedOptionGroups"
+                                 (selectGroup)="toggleSelectedGroup($event)"></vdr-select-option-group>
+    </clr-wizard-page>
+
+    <clr-wizard-page>
+        <ng-template clrPageTitle>{{ 'common.confirm' | translate }}</ng-template>
+        <h4>{{ 'catalog.selected-option-groups' | translate }}:</h4>
+        <vdr-chip *ngFor="let selectedGroup of selectedOptionGroups">
+            {{ selectedGroup.code }}
+        </vdr-chip>
+        <h5>{{ 'catalog.confirm-generate-product-variants' | translate: { count: getVariantCount() } }}</h5>
+        <ol class="list">
+            <li *ngFor="let item of productVariantPreviewList">{{ item }}</li>
+        </ol>
+    </clr-wizard-page>
+</clr-wizard>

+ 0 - 0
admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.scss


+ 41 - 0
admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts

@@ -0,0 +1,41 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { TestingCommonModule } from '../../../../testing/testing-common.module';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { MockNotificationService } from '../../../core/providers/notification/notification.service.mock';
+import { ChipComponent } from '../../../shared/components/chip/chip.component';
+import { SelectToggleComponent } from '../../../shared/components/select-toggle/select-toggle.component';
+import { CreateOptionGroupFormComponent } from '../create-option-group-form/create-option-group-form.component';
+import { SelectOptionGroupComponent } from '../select-option-group/select-option-group.component';
+
+import { ProductVariantsWizardComponent } from './product-variants-wizard.component';
+
+describe('ProductVariantsWizardComponent', () => {
+    let component: ProductVariantsWizardComponent;
+    let fixture: ComponentFixture<ProductVariantsWizardComponent>;
+
+    beforeEach(async(() => {
+        TestBed.configureTestingModule({
+            imports: [TestingCommonModule, ReactiveFormsModule],
+            declarations: [
+                ProductVariantsWizardComponent,
+                SelectOptionGroupComponent,
+                CreateOptionGroupFormComponent,
+                SelectToggleComponent,
+                ChipComponent,
+            ],
+            providers: [{ provide: NotificationService, useClass: MockNotificationService }],
+        }).compileComponents();
+    }));
+
+    beforeEach(() => {
+        fixture = TestBed.createComponent(ProductVariantsWizardComponent);
+        component = fixture.componentInstance;
+        fixture.detectChanges();
+    });
+
+    it('should create', () => {
+        expect(component).toBeTruthy();
+    });
+});

+ 97 - 0
admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.ts

@@ -0,0 +1,97 @@
+import { Component, Input, OnChanges, ViewChild } from '@angular/core';
+import { ClrWizard } from '@clr/angular';
+import { forkJoin, Observable } from 'rxjs';
+import { map, mergeMap, take, takeUntil } from 'rxjs/operators';
+
+import { generateAllCombinations } from '../../../../../../shared/shared-utils';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { ProductOptionGroup, ProductWithVariants } from '../../../data/types/gql-generated-types';
+import { CreateOptionGroupFormComponent } from '../create-option-group-form/create-option-group-form.component';
+import { SelectOptionGroupComponent } from '../select-option-group/select-option-group.component';
+
+@Component({
+    selector: 'vdr-product-variants-wizard',
+    templateUrl: './product-variants-wizard.component.html',
+    styleUrls: ['./product-variants-wizard.component.scss'],
+})
+export class ProductVariantsWizardComponent implements OnChanges {
+    @Input() product: ProductWithVariants;
+    @ViewChild('wizard') wizard: ClrWizard;
+    @ViewChild('createOptionGroupForm') createOptionGroupForm: CreateOptionGroupFormComponent;
+    @ViewChild('selectOptionGroup') selectOptionGroup: SelectOptionGroupComponent;
+    selectedOptionGroups: Array<Partial<ProductOptionGroup>> = [];
+    productVariantPreviewList: string[] = [];
+
+    constructor(private notificationService: NotificationService, private dataService: DataService) {}
+
+    ngOnChanges() {
+        if (this.product) {
+            this.selectedOptionGroups = this.product.optionGroups;
+        }
+    }
+
+    /**
+     * Opens the wizard and begins the steps.
+     */
+    start(): Observable<ProductWithVariants> {
+        this.wizard.open();
+
+        return this.wizard.wizardFinished.pipe(
+            takeUntil(this.wizard.onCancel),
+            take(1),
+            mergeMap(() => {
+                const addOptionsOperations = this.selectedOptionGroups.map(og => {
+                    if (og.id) {
+                        return this.dataService.product.addOptionGroupToProduct({
+                            productId: this.product.id,
+                            optionGroupId: og.id,
+                        });
+                    } else {
+                        return [];
+                    }
+                });
+
+                return forkJoin(addOptionsOperations);
+            }),
+            map(() => this.product),
+        );
+    }
+
+    createOptionGroup() {
+        this.createOptionGroupForm.createOptionGroup().subscribe(data => {
+            this.toggleSelectedGroup(data.createProductOptionGroup);
+            this.notificationService.success(_('catalog.notify-create-new-option-group'));
+            this.createOptionGroupForm.resetForm();
+        });
+    }
+
+    toggleSelectedGroup(optionGroup: ProductOptionGroup) {
+        const selected = !!this.selectedOptionGroups.find(og => og.id === optionGroup.id);
+        if (selected) {
+            this.selectedOptionGroups = this.selectedOptionGroups.filter(og => og.id !== optionGroup.id);
+        } else {
+            this.selectedOptionGroups = this.selectedOptionGroups.concat(optionGroup);
+        }
+        this.generateVariantPreviews();
+    }
+
+    /**
+     * The total number of variants to be generated is the product of all the options in the
+     * selected option groups.
+     */
+    getVariantCount(): number {
+        return this.selectedOptionGroups.reduce((total, og) => {
+            const length = og.options ? og.options.length || 1 : 1;
+            return total * length;
+        }, 1);
+    }
+
+    private generateVariantPreviews() {
+        const optionsArray = this.selectedOptionGroups.map(og => og.options || []);
+        this.productVariantPreviewList = generateAllCombinations(optionsArray).map(options => {
+            return `${this.product.name} ${options.map(o => o.name).join(' ')}`;
+        });
+    }
+}

+ 1 - 1
admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.html

@@ -1,6 +1,6 @@
 <ng-template vdrDialogTitle>{{ 'catalog.select-option-group' | translate }}</ng-template>
 
-<vdr-select-option-group [existingOptionGroupIds]="existingOptionGroupIds"
+<vdr-select-option-group [selectedGroups]="existingOptionGroups"
                          (selectGroup)="selectGroup($event)"></vdr-select-option-group>
 
 <ng-template vdrDialogButtons>

+ 5 - 4
admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.ts

@@ -1,10 +1,11 @@
 import { ChangeDetectionStrategy, Component } from '@angular/core';
 
-import { GetProductOptionGroups_productOptionGroups } from '../../../data/types/gql-generated-types';
+import {
+    GetProductOptionGroups_productOptionGroups,
+    ProductOptionGroup,
+} from '../../../data/types/gql-generated-types';
 import { Dialog } from '../../../shared/providers/modal/modal.service';
 
-export type ProductOptionGroup = GetProductOptionGroups_productOptionGroups;
-
 @Component({
     selector: 'vdr-select-option-group-dialog',
     templateUrl: './select-option-group-dialog.component.html',
@@ -12,7 +13,7 @@ export type ProductOptionGroup = GetProductOptionGroups_productOptionGroups;
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class SelectOptionGroupDialogComponent implements Dialog<ProductOptionGroup> {
-    existingOptionGroupIds: string[];
+    existingOptionGroups: Array<Partial<ProductOptionGroup>>;
     resolveWith: (result?: ProductOptionGroup) => void;
 
     selectGroup(group: ProductOptionGroup) {

+ 21 - 8
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.html

@@ -4,21 +4,34 @@
        [formControl]="filterInput">
 <div class="group-list">
     <div class="group" *ngFor="let group of optionGroups$ | async">
-        <div class="select-button">
-            <button class="btn btn-icon btn-primary"
-                    [disabled]="!isAvailable(group)"
-                    (click)="selectGroup.emit(group)">
-                <clr-icon shape="check"></clr-icon>
-            </button>
-        </div>
+        <vdr-select-toggle [selected]="isSelected(group)"
+                           (selectedChange)="selectGroup.emit(group)"></vdr-select-toggle>
         <div class="name-code">
             <div class="name">{{ group.name }}</div>
             <div class="code">{{ group.code }}</div>
         </div>
         <div class="options">
-            <div class="option" *ngFor="let option of group.options">
+            <vdr-chip class="option" *ngFor="let option of group.options | slice:0:truncateOptionsTo">
                 {{ option.name }}
+            </vdr-chip>
+            <div *ngIf="optionsTruncated(group)">
+                <clr-signpost>
+                    <a clrSignpostTrigger>
+                        (+ {{ 'catalog.truncated-options-count' | translate: { count: optionsTrucatedCount(group) } }})
+                    </a>
+                    <clr-signpost-content [clrPosition]="'bottom-middle'" *clrIfOpen>
+                        <ul class="full-options-list">
+                            <li *ngFor="let option of group.options">{{ option.name }}</li>
+                        </ul>
+                    </clr-signpost-content>
+                </clr-signpost>
             </div>
         </div>
     </div>
 </div>
+<h5>{{ 'catalog.selected-option-groups' | translate }}:</h5>
+<div class="selected-groups">
+    <vdr-chip *ngFor="let selectedGroup of selectedGroups">
+        {{ selectedGroup.code }}
+    </vdr-chip>
+</div>

+ 11 - 11
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.scss

@@ -11,6 +11,10 @@
     overflow: auto;
 }
 
+vdr-select-toggle {
+    margin-right: 12px;
+}
+
 .group {
     display: flex;
     padding: 6px 12px;
@@ -27,16 +31,12 @@
     .options {
         text-align: right;
     }
+}
 
-    .option {
-        display: inline-block;
-        padding: 0 6px;
-        border: 1px solid $color-grey-3;
-        color: $color-grey-5;
-        border-radius: 3px;
-        margin-right: 3px;
-        &:last-of-type {
-            margin-right: 0;
-        }
-    }
+.selected-groups {
+    padding: 12px;
+}
+
+.full-options-list {
+    text-align: left;
 }

+ 4 - 4
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts

@@ -1,10 +1,10 @@
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 import { ReactiveFormsModule } from '@angular/forms';
-import { ClrIconCustomTag } from '@clr/angular';
-import { TranslateModule } from '@ngx-translate/core';
 
+import { TestingCommonModule } from '../../../../testing/testing-common.module';
 import { DataService } from '../../../data/providers/data.service';
 import { MockDataService } from '../../../data/providers/data.service.mock';
+import { ChipComponent } from '../../../shared/components/chip/chip.component';
 import { SelectToggleComponent } from '../../../shared/components/select-toggle/select-toggle.component';
 
 import { SelectOptionGroupComponent } from './select-option-group.component';
@@ -15,8 +15,8 @@ describe('SelectOptionGroupComponent', () => {
 
     beforeEach(async(() => {
         TestBed.configureTestingModule({
-            imports: [ReactiveFormsModule, TranslateModule.forRoot()],
-            declarations: [SelectOptionGroupComponent, SelectToggleComponent, ClrIconCustomTag],
+            imports: [ReactiveFormsModule, TestingCommonModule],
+            declarations: [SelectOptionGroupComponent, SelectToggleComponent, ChipComponent],
             providers: [{ provide: DataService, useClass: MockDataService }],
         }).compileComponents();
     }));

+ 35 - 9
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.ts

@@ -3,6 +3,7 @@ import {
     Component,
     EventEmitter,
     Input,
+    OnChanges,
     OnDestroy,
     OnInit,
     Output,
@@ -11,8 +12,14 @@ import { FormControl } from '@angular/forms';
 import { Observable, Subject } from 'rxjs';
 import { debounceTime, map, takeUntil } from 'rxjs/operators';
 
+import { DeepPartial } from '../../../../../../shared/shared-types';
 import { DataService } from '../../../data/providers/data.service';
-import { ProductOptionGroup } from '../select-option-group-dialog/select-option-group-dialog.component';
+import {
+    GetProductOptionGroups,
+    GetProductOptionGroupsVariables,
+    ProductOptionGroup,
+} from '../../../data/types/gql-generated-types';
+import { QueryResult } from '../../../data/types/query-result';
 
 @Component({
     selector: 'vdr-select-option-group',
@@ -20,18 +27,21 @@ import { ProductOptionGroup } from '../select-option-group-dialog/select-option-
     styleUrls: ['./select-option-group.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class SelectOptionGroupComponent implements OnInit, OnDestroy {
-    @Input() existingOptionGroupIds: string[];
+export class SelectOptionGroupComponent implements OnInit, OnChanges, OnDestroy {
+    @Input() selectedGroups: ProductOptionGroup[];
     @Output() selectGroup = new EventEmitter<ProductOptionGroup>();
-    optionGroups$: Observable<ProductOptionGroup[]>;
+    optionGroups$: Observable<Array<DeepPartial<ProductOptionGroup>>>;
     filterInput = new FormControl();
+    optionGroupsQuery: QueryResult<GetProductOptionGroups, GetProductOptionGroupsVariables>;
+    truncateOptionsTo = 4;
+    private inputChange$ = new Subject<ProductOptionGroup[]>();
     private destroy$ = new Subject<void>();
 
     constructor(private dataService: DataService) {}
 
     ngOnInit() {
-        const optionGroupsQuery = this.dataService.product.getProductOptionGroups();
-        this.optionGroups$ = optionGroupsQuery.stream$.pipe(map(data => data.productOptionGroups));
+        this.optionGroupsQuery = this.dataService.product.getProductOptionGroups();
+        this.optionGroups$ = this.optionGroupsQuery.stream$.pipe(map(data => data.productOptionGroups));
 
         this.filterInput.valueChanges
             .pipe(
@@ -39,16 +49,32 @@ export class SelectOptionGroupComponent implements OnInit, OnDestroy {
                 takeUntil(this.destroy$),
             )
             .subscribe(filterTerm => {
-                optionGroupsQuery.ref.refetch({ filterTerm });
+                this.optionGroupsQuery.ref.refetch({ filterTerm });
             });
     }
 
+    ngOnChanges() {
+        this.inputChange$.next(this.selectedGroups);
+    }
+
     ngOnDestroy() {
         this.destroy$.next();
         this.destroy$.complete();
     }
 
-    isAvailable(group: ProductOptionGroup): boolean {
-        return !this.existingOptionGroupIds.includes(group.id);
+    refresh() {
+        this.optionGroupsQuery.ref.refetch();
+    }
+
+    isSelected(group: ProductOptionGroup): boolean {
+        return this.selectedGroups && !!this.selectedGroups.find(g => g.id === group.id);
+    }
+
+    optionsTruncated(group: ProductOptionGroup): boolean {
+        return 0 < this.optionsTrucatedCount(group);
+    }
+
+    optionsTrucatedCount(group: ProductOptionGroup): number {
+        return Math.max(group.options.length - this.truncateOptionsTo, 0);
     }
 }

+ 3 - 0
admin-ui/src/app/core/components/notification/notification.component.scss

@@ -6,6 +6,9 @@
 }
 
 :host {
+    position: relative;
+    z-index: 1050;
+
     > .notification-wrapper {
         display: block;
         position: fixed;

+ 1 - 0
admin-ui/src/app/data/fragments/product-fragments.ts

@@ -61,6 +61,7 @@ export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
         options {
             id
             languageCode
+            name
             code
             translations {
                 name

+ 2 - 0
admin-ui/src/app/data/types/gql-generated-types.ts

@@ -352,6 +352,7 @@ export interface CreateProductOptionGroup_createProductOptionGroup_options {
     __typename: 'ProductOption';
     id: string;
     languageCode: LanguageCode | null;
+    name: string | null;
     code: string | null;
     translations: CreateProductOptionGroup_createProductOptionGroup_options_translations[];
 }
@@ -749,6 +750,7 @@ export interface ProductOptionGroup_options {
     __typename: 'ProductOption';
     id: string;
     languageCode: LanguageCode | null;
+    name: string | null;
     code: string | null;
     translations: ProductOptionGroup_options_translations[];
 }

+ 1 - 0
admin-ui/src/app/shared/components/chip/chip.component.scss

@@ -4,6 +4,7 @@
     display: inline-flex;
     border: 1px solid $color-grey-4;
     border-radius: 3px;
+    margin: 6px;
 }
 
 .chip-label {

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

@@ -11,6 +11,7 @@ import {
     ActionBarLeftComponent,
     ActionBarRightComponent,
 } from './components/action-bar/action-bar.component';
+import { ChipComponent } from './components/chip/chip.component';
 import { DataTableColumnComponent } from './components/data-table/data-table-column.component';
 import { DataTableComponent } from './components/data-table/data-table.component';
 import { FormFieldControlDirective } from './components/form-field/form-field-control.directive';
@@ -39,6 +40,7 @@ const DECLARATIONS = [
     ActionBarComponent,
     ActionBarLeftComponent,
     ActionBarRightComponent,
+    ChipComponent,
     DataTableComponent,
     DataTableColumnComponent,
     PaginationControlsComponent,

+ 27 - 1
admin-ui/src/i18n-messages/en.json

@@ -7,10 +7,21 @@
     "ID": "ID",
     "add-existing-option-group": "Add existing option group",
     "add-option-group": "Add option group",
+    "confirm-generate-product-variants": "Click 'Finish' to generate {count} product variants.",
     "create-group": "Create option group",
     "create-new-option-group": "Create new option group",
+    "create-new-product": "Create new product",
     "description": "Description",
+    "filter-by-group-name": "Filter by group name",
+    "generate-product-variants": "Generate product variants",
+    "generate-variants-default-only": "This product does not have options",
+    "generate-variants-with-options": "This product has options",
     "name": "Name",
+    "notify-create-new-option-group": "Created new option group",
+    "notify-create-product-error": "An error occured, could not create product",
+    "notify-create-product-success": "Created new product",
+    "notify-update-product-error": "An error occurred, could not update product",
+    "notify-update-product-success": "Updated product",
     "option-group-code": "Code",
     "option-group-name": "Option group name",
     "option-group-options-label": "Options",
@@ -18,20 +29,35 @@
     "product": "Product",
     "product-name": "Product name",
     "product-option-groups": "Option groups",
+    "product-variant-table-name": "Variant name",
+    "product-variant-table-options": "Options",
+    "product-variant-table-price": "Price",
+    "product-variant-table-sku": "SKU",
     "product-variants": "Product variants",
-    "slug": "Slug"
+    "select-option-group": "Select option group",
+    "selected-option-groups": "Selected option groups",
+    "slug": "Slug",
+    "truncated-options-count": "{count} further {count, plural, one {option} other {options}}"
   },
   "common": {
+    "back": "Back",
     "cancel": "Cancel",
+    "confirm": "Confirm",
+    "create": "Create",
     "edit": "Edit",
+    "finish": "Finish",
     "language": "Language",
     "log-out": "Log out",
     "login": "Log in",
+    "next": "Next",
     "password": "Password",
     "remember-me": "Remember me",
     "update": "Update",
     "username": "Username"
   },
+  "error": {
+    "product-variant-form-values-do-not-match": "The number of variants in the product form does not match the actual number of variants"
+  },
   "nav": {
     "catalog": "Catalog",
     "categories": "Categories",