Browse Source

feat(admin-ui): Implement integrated ProductVariant creation

Relates to #124
Michael Bromley 6 years ago
parent
commit
58dad1d27a
29 changed files with 558 additions and 673 deletions
  1. 3 17
      admin-ui/src/app/catalog/catalog.module.ts
  2. 0 21
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.html
  3. 0 0
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.scss
  4. 0 27
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.ts
  5. 0 15
      admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.html
  6. 0 0
      admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.scss
  7. 0 28
      admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.spec.ts
  8. 0 82
      admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.ts
  9. 97 15
      admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.html
  10. 29 0
      admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.scss
  11. 82 14
      admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.ts
  12. 21 0
      admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.html
  13. 44 0
      admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.scss
  14. 98 0
      admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.ts
  15. 29 28
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  16. 97 4
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  17. 0 55
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.html
  18. 0 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.scss
  19. 0 103
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.ts
  20. 0 10
      admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.html
  21. 0 0
      admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.scss
  22. 0 23
      admin-ui/src/app/catalog/components/select-option-group-dialog/select-option-group-dialog.component.ts
  23. 0 43
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.html
  24. 0 42
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.scss
  25. 0 76
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.ts
  26. 37 29
      admin-ui/src/app/common/generated-types.ts
  27. 5 15
      admin-ui/src/app/data/definitions/product-definitions.ts
  28. 10 7
      admin-ui/src/app/data/providers/product-data.service.ts
  29. 6 19
      admin-ui/src/i18n-messages/en.json

+ 3 - 17
admin-ui/src/app/catalog/catalog.module.ts

@@ -16,20 +16,16 @@ import { CollectionDetailComponent } from './components/collection-detail/collec
 import { CollectionListComponent } from './components/collection-list/collection-list.component';
 import { CollectionTreeNodeComponent } from './components/collection-tree/collection-tree-node.component';
 import { CollectionTreeComponent } from './components/collection-tree/collection-tree.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 { GenerateProductVariantsComponent } from './components/generate-product-variants/generate-product-variants.component';
+import { OptionValueInputComponent } from './components/option-value-input/option-value-input.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 { 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';
-import { SelectOptionGroupDialogComponent } from './components/select-option-group-dialog/select-option-group-dialog.component';
-import { SelectOptionGroupComponent } from './components/select-option-group/select-option-group.component';
 import { VariantPriceDetailComponent } from './components/variant-price-detail/variant-price-detail.component';
 import { CollectionResolver } from './providers/routing/collection-resolver';
 import { FacetResolver } from './providers/routing/facet-resolver';
@@ -41,11 +37,6 @@ import { ProductResolver } from './providers/routing/product-resolver';
     declarations: [
         ProductListComponent,
         ProductDetailComponent,
-        CreateOptionGroupDialogComponent,
-        ProductVariantsWizardComponent,
-        SelectOptionGroupDialogComponent,
-        CreateOptionGroupFormComponent,
-        SelectOptionGroupComponent,
         FacetListComponent,
         FacetDetailComponent,
         GenerateProductVariantsComponent,
@@ -65,14 +56,9 @@ import { ProductResolver } from './providers/routing/product-resolver';
         ProductVariantsTableComponent,
         AssetPreviewComponent,
         ProductSearchInputComponent,
+        OptionValueInputComponent,
     ],
-    entryComponents: [
-        AssetPickerDialogComponent,
-        CreateOptionGroupDialogComponent,
-        SelectOptionGroupDialogComponent,
-        ApplyFacetDialogComponent,
-        AssetPreviewComponent,
-    ],
+    entryComponents: [AssetPickerDialogComponent, ApplyFacetDialogComponent, AssetPreviewComponent],
     providers: [ProductResolver, FacetResolver, CollectionResolver],
 })
 export class CatalogModule {}

+ 0 - 21
admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.html

@@ -1,21 +0,0 @@
-<ng-template vdrDialogTitle>{{ 'catalog.create-new-option-group' | translate }}</ng-template>
-
-<vdr-create-option-group-form
-    #createOptionGroupForm
-    [productId]="productId"
-    [productName]="productName"
-></vdr-create-option-group-form>
-
-<ng-template vdrDialogButtons>
-    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
-    <button
-        type="submit"
-        (click)="createOptionGroup()"
-        class="btn btn-primary"
-        [disabled]="
-            createOptionGroupForm.optionGroupForm.invalid || createOptionGroupForm.optionGroupForm.pristine
-        "
-    >
-        {{ 'catalog.create-group' | translate }}
-    </button>
-</ng-template>

+ 0 - 0
admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.scss


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

@@ -1,27 +0,0 @@
-import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core';
-
-import { CreateProductOptionGroup } from '../../../common/generated-types';
-import { Dialog } from '../../../shared/providers/modal/modal.service';
-import { CreateOptionGroupFormComponent } from '../create-option-group-form/create-option-group-form.component';
-
-@Component({
-    selector: 'vdr-create-option-group-dialog',
-    templateUrl: './create-option-group-dialog.component.html',
-    styleUrls: ['./create-option-group-dialog.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class CreateOptionGroupDialogComponent implements Dialog<CreateProductOptionGroup.Mutation> {
-    productId: string;
-    productName: string;
-    @ViewChild('createOptionGroupForm', { static: true })
-    createOptionGroupForm: CreateOptionGroupFormComponent;
-    resolveWith: (result?: CreateProductOptionGroup.Mutation) => void;
-
-    createOptionGroup() {
-        this.createOptionGroupForm.createOptionGroup().subscribe(data => this.resolveWith(data));
-    }
-
-    cancel() {
-        this.resolveWith();
-    }
-}

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

@@ -1,15 +0,0 @@
-<form class="form" [formGroup]="optionGroupForm">
-    <vdr-form-field [label]="'catalog.option-group-name' | translate" for="name">
-        <input id="name" type="text" formControlName="name" (input)="updateCode($event.target.value)" />
-    </vdr-form-field>
-    <vdr-form-field [label]="'catalog.option-group-code' | translate" for="code" [readOnlyToggle]="true">
-        <input id="code" type="text" formControlName="code" />
-    </vdr-form-field>
-    <vdr-form-field
-        [label]="'catalog.option-group-options-label' | translate"
-        [tooltip]="'catalog.option-group-options-tooltip' | translate: { defaultLanguage: defaultLanguage }"
-        for="options"
-    >
-        <textarea id="options" type="text" formControlName="options" rows="10"></textarea>
-    </vdr-form-field>
-</form>

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


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

@@ -1,28 +0,0 @@
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
-
-import { TestingCommonModule } from '../../../../testing/testing-common.module';
-
-import { CreateOptionGroupFormComponent } from './create-option-group-form.component';
-
-describe('CreateOptionGroupFormComponent', () => {
-    let component: CreateOptionGroupFormComponent;
-    let fixture: ComponentFixture<CreateOptionGroupFormComponent>;
-
-    beforeEach(async(() => {
-        TestBed.configureTestingModule({
-            imports: [TestingCommonModule, ReactiveFormsModule],
-            declarations: [CreateOptionGroupFormComponent],
-        }).compileComponents();
-    }));
-
-    beforeEach(() => {
-        fixture = TestBed.createComponent(CreateOptionGroupFormComponent);
-        component = fixture.componentInstance;
-        fixture.detectChanges();
-    });
-
-    it('should create', () => {
-        expect(component).toBeTruthy();
-    });
-});

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

@@ -1,82 +0,0 @@
-import { Component, Input, OnInit } from '@angular/core';
-import { FormBuilder, FormGroup } from '@angular/forms';
-import { Observable } from 'rxjs';
-import { normalizeString } from 'shared/normalize-string';
-
-import {
-    CreateProductOptionGroup,
-    CreateProductOptionGroupInput,
-    CreateProductOptionInput,
-} from '../../../common/generated-types';
-import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
-import { DataService } from '../../../data/providers/data.service';
-
-@Component({
-    selector: 'vdr-create-option-group-form',
-    templateUrl: './create-option-group-form.component.html',
-    styleUrls: ['./create-option-group-form.component.scss'],
-})
-export class CreateOptionGroupFormComponent implements OnInit {
-    @Input() productName = '';
-    @Input() productId: string;
-    optionGroupForm: FormGroup;
-    readonly defaultLanguage = getDefaultLanguage();
-
-    constructor(private formBuilder: FormBuilder, private dataService: DataService) {}
-
-    ngOnInit() {
-        this.optionGroupForm = this.formBuilder.group({
-            name: '',
-            code: '',
-            options: '',
-        });
-    }
-
-    resetForm() {
-        this.optionGroupForm.reset();
-    }
-
-    updateCode(nameValue: string) {
-        const codeControl = this.optionGroupForm.get('code');
-        if (codeControl && codeControl.pristine) {
-            codeControl.setValue(normalizeString(`${this.productName} ${nameValue}`, '-'));
-        }
-    }
-
-    createOptionGroup(): Observable<CreateProductOptionGroup.Mutation> {
-        return this.dataService.product.createProductOptionGroups(this.createGroupFromForm());
-    }
-
-    private createGroupFromForm(): CreateProductOptionGroupInput {
-        const name = this.optionGroupForm.value.name;
-        const code = this.optionGroupForm.value.code;
-        const rawOptions = this.optionGroupForm.value.options;
-        return {
-            code,
-            translations: [
-                {
-                    languageCode: getDefaultLanguage(),
-                    name,
-                },
-            ],
-            options: this.createGroupOptions(rawOptions),
-        };
-    }
-
-    private createGroupOptions(rawOptions: string): CreateProductOptionInput[] {
-        return rawOptions
-            .split('\n')
-            .map(line => line.trim())
-            .map(name => {
-                return {
-                    code: normalizeString(name, '-'),
-                    translations: [
-                        {
-                            languageCode: getDefaultLanguage(),
-                            name,
-                        },
-                    ],
-                };
-            });
-    }
-}

+ 97 - 15
admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.html

@@ -1,17 +1,99 @@
-<vdr-dropdown>
-    <button type="button" class="btn btn-primary" vdrDropdownTrigger>
-        <clr-icon shape="add"></clr-icon>
-        {{ 'catalog.generate-product-variants' | translate }}
-        <clr-icon shape="caret down"></clr-icon>
-    </button>
-    <vdr-dropdown-menu vdrPosition="bottom-right">
-        <button type="button" vdrDropdownItem (click)="generateProductVariants()">
-            {{ 'catalog.generate-variants-default-only' | translate }}
+<div *ngFor="let group of optionGroups" class="option-groups">
+    <div class="name">
+        <label>{{ 'catalog.option' | translate }}</label>
+        <input
+            placeholder="e.g. Size"
+            clrInput
+            [(ngModel)]="group.name"
+            name="name"
+            required
+            (keydown.enter)="handleEnter($event, optionValueInputComponent)"
+        />
+    </div>
+    <div class="values">
+        <label>{{ 'catalog.option-values' | translate }}</label>
+        <vdr-option-value-input
+            #optionValueInputComponent
+            [(ngModel)]="group.values"
+            (ngModelChange)="generateVariants()"
+            [groupName]="group.name"
+            [disabled]="group.name === ''"
+        ></vdr-option-value-input>
+    </div>
+    <div class="remove-group">
+        <button
+            class="btn btn-icon btn-warning-outline"
+            [title]="'catalog.remove-option' | translate"
+            (click)="removeOption(group.name)"
+        >
+            <clr-icon shape="trash"></clr-icon>
         </button>
-        <button type="button" vdrDropdownItem (click)="startProductVariantsWizard()">
-            {{ 'catalog.generate-variants-with-options' | translate }}
-        </button>
-    </vdr-dropdown-menu>
-</vdr-dropdown>
+    </div>
+</div>
+<button class="btn btn-primary-outline btn-sm" (click)="addOption()">
+    <clr-icon shape="plus"></clr-icon>
+    {{ 'catalog.add-option' | translate }}
+</button>
 
-<vdr-product-variants-wizard #productVariantsWizard [product]="product"></vdr-product-variants-wizard>
+<div class="variants-preview">
+    <table class="table">
+        <thead>
+            <tr>
+                <th *ngIf="1 < variants.length">{{ 'common.create' | translate }}</th>
+                <th *ngIf="1 < variants.length">{{ 'catalog.variant' | translate }}</th>
+                <th>{{ 'catalog.sku' | translate }}</th>
+                <th>{{ 'catalog.price' | translate }}</th>
+                <th>{{ 'catalog.stock-on-hand' | translate }}</th>
+            </tr>
+        </thead>
+        <tr
+            *ngFor="let variant of variants; trackBy: trackByFn"
+            [class.disabled]="!variantFormValues[variant.id].enabled"
+        >
+            <td *ngIf="1 < variants.length">
+                <input
+                    type="checkbox"
+                    (change)="onFormChange()"
+                    [(ngModel)]="variantFormValues[variant.id].enabled"
+                    clrCheckbox
+                />
+            </td>
+            <td *ngIf="1 < variants.length">
+                {{ variant.values.join(' ') }}
+            </td>
+            <td>
+                <clr-input-container>
+                    <input
+                        clrInput
+                        type="text"
+                        (change)="onFormChange()"
+                        [(ngModel)]="variantFormValues[variant.id].sku"
+                        [placeholder]="'catalog.sku' | translate"
+                    />
+                </clr-input-container>
+            </td>
+            <td>
+                <clr-input-container>
+                    <vdr-currency-input
+                        clrInput
+                        [(ngModel)]="variantFormValues[variant.id].price"
+                        (ngModelChange)="onFormChange()"
+                        [currencyCode]="currencyCode"
+                    ></vdr-currency-input>
+                </clr-input-container>
+            </td>
+            <td>
+                <clr-input-container>
+                    <input
+                        clrInput
+                        type="number"
+                        [(ngModel)]="variantFormValues[variant.id].stock"
+                        (change)="onFormChange()"
+                        min="0"
+                        step="1"
+                    />
+                </clr-input-container>
+            </td>
+        </tr>
+    </table>
+</div>

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

@@ -1,3 +1,32 @@
+@import "variables";
+
 :host {
     display: block;
+    margin-bottom: 120px;
+}
+
+.option-groups {
+    display: flex;
+}
+
+::ng-deep ng-dropdown-panel {
+    display: none;
+}
+
+.values {
+    flex: 1;
+    margin: 0 6px;
+}
+
+.remove-group {
+    padding-top: 18px;
+}
+
+.variants-preview {
+    tr.disabled {
+        td {
+            background-color: $color-grey-100;
+            color: $color-grey-400;
+        }
+    }
 }

+ 82 - 14
admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.ts

@@ -1,29 +1,97 @@
-import { Component, Input, ViewChild } from '@angular/core';
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { generateAllCombinations } from 'shared/shared-utils';
 
-import { ProductWithVariants } from '../../../common/generated-types';
+import { CurrencyCode } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
-import { ProductVariantsWizardComponent } from '../product-variants-wizard/product-variants-wizard.component';
+import { OptionValueInputComponent } from '../option-value-input/option-value-input.component';
+
+const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
+export type CreateVariantValues = {
+    optionValues: string[];
+    enabled: boolean;
+    sku: string;
+    price: number;
+    stock: number;
+};
+export type CreateProductVariantsConfig = {
+    groups: Array<{ name: string; values: string[] }>;
+    variants: CreateVariantValues[];
+};
 
 @Component({
     selector: 'vdr-generate-product-variants',
     templateUrl: './generate-product-variants.component.html',
     styleUrls: ['./generate-product-variants.component.scss'],
 })
-export class GenerateProductVariantsComponent {
-    @Input() product: ProductWithVariants.Fragment;
-    @ViewChild('productVariantsWizard', { static: true })
-    productVariantsWizard: ProductVariantsWizardComponent;
+export class GenerateProductVariantsComponent implements OnInit {
+    @Output() variantsChange = new EventEmitter<CreateProductVariantsConfig>();
+    optionGroups: Array<{ name: string; values: string[] }> = [];
+    currencyCode: CurrencyCode;
+    variants: Array<{ id: string; values: string[] }>;
+    variantFormValues: { [id: string]: CreateVariantValues } = {};
     constructor(private dataService: DataService) {}
 
-    startProductVariantsWizard() {
-        this.productVariantsWizard.start().subscribe(({ defaultPrice, defaultSku }) => {
-            this.generateProductVariants(defaultPrice, defaultSku);
+    ngOnInit() {
+        this.dataService.settings.getActiveChannel().single$.subscribe(data => {
+            this.currencyCode = data.activeChannel.currencyCode;
+        });
+
+        this.generateVariants();
+    }
+
+    addOption() {
+        this.optionGroups.push({ name: '', values: [] });
+    }
+
+    removeOption(name: string) {
+        this.optionGroups = this.optionGroups.filter(g => g.name !== name);
+        this.generateVariants();
+    }
+
+    generateVariants() {
+        const totalValuesCount = this.optionGroups.reduce((sum, group) => sum + group.values.length, 0);
+        const groups = totalValuesCount ? this.optionGroups.map(g => g.values) : [[DEFAULT_VARIANT_CODE]];
+        this.variants = generateAllCombinations(groups).map(values => ({ id: values.join('|'), values }));
+
+        this.variants.forEach(variant => {
+            if (!this.variantFormValues[variant.id]) {
+                this.variantFormValues[variant.id] = {
+                    optionValues: variant.values,
+                    enabled: true,
+                    price: this.copyFromDefault(variant.id, 'price', 0),
+                    sku: this.copyFromDefault(variant.id, 'sku', ''),
+                    stock: this.copyFromDefault(variant.id, 'stock', 0),
+                };
+            }
+        });
+        this.onFormChange();
+    }
+
+    trackByFn(index: number, variant: { name: string; values: string[] }) {
+        return variant.values.join('|');
+    }
+
+    handleEnter(event: KeyboardEvent, optionValueInputComponent: OptionValueInputComponent) {
+        event.preventDefault();
+        event.stopPropagation();
+        optionValueInputComponent.focus();
+    }
+
+    onFormChange() {
+        const variantsToCreate = this.variants.map(v => this.variantFormValues[v.id]).filter(v => v.enabled);
+        this.variantsChange.emit({
+            groups: this.optionGroups,
+            variants: variantsToCreate,
         });
     }
 
-    generateProductVariants(defaultPrice?: number, defaultSku?: string) {
-        this.dataService.product
-            .generateProductVariants(this.product.id, defaultPrice, defaultSku)
-            .subscribe();
+    private copyFromDefault<T extends keyof CreateVariantValues>(
+        variantId: string,
+        prop: T,
+        value: CreateVariantValues[T],
+    ): CreateVariantValues[T] {
+        return variantId !== DEFAULT_VARIANT_CODE
+            ? this.variantFormValues[DEFAULT_VARIANT_CODE][prop]
+            : value;
     }
 }

+ 21 - 0
admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.html

@@ -0,0 +1,21 @@
+<div class="input-wrapper" [class.focus]="isFocussed" (click)="textArea.focus()">
+    <div class="chips" *ngIf="0 < options.length">
+        <vdr-chip
+            *ngFor="let option of options; last as isLast"
+            icon="times"
+            [class.selected]="isLast && lastSelected"
+            [colorFrom]="groupName"
+            (iconClick)="removeOption(option)"
+        >
+            {{ option }}
+        </vdr-chip>
+    </div>
+    <textarea
+        #textArea
+        (keyup)="handleKey($event)"
+        (focus)="isFocussed = true"
+        (blur)="isFocussed = false"
+        [(ngModel)]="input"
+        [disabled]="disabled"
+    ></textarea>
+</div>

+ 44 - 0
admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.scss

@@ -0,0 +1,44 @@
+@import "variables";
+
+.input-wrapper {
+    background-color: white;
+    border-radius: 3px !important;
+    border: 1px solid $color-grey-300 !important;
+    cursor: text;
+
+    &.focus {
+        border-color: $color-primary-500 !important;
+        box-shadow: 0 0 1px 1px $color-primary-100;
+    }
+
+    .chips {
+        padding: 5px;
+    }
+
+    textarea {
+        border: none;
+        width: 100%;
+        height: 24px;
+        margin-top: 3px;
+        padding: 0 6px;
+        &:focus {
+            outline: none;
+        }
+        &:disabled {
+            background-color: $color-grey-100;
+        }
+    }
+}
+
+vdr-chip {
+    ::ng-deep .wrapper {
+        margin: 0 3px;
+    }
+    &.selected {
+        ::ng-deep .wrapper {
+            border-color: $color-warning-500 !important;
+            box-shadow: 0 0 1px 1px $color-warning-400;
+            opacity: 0.6;
+        }
+    }
+}

+ 98 - 0
admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.ts

@@ -0,0 +1,98 @@
+import {
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ElementRef,
+    forwardRef,
+    Input,
+    Provider,
+    ViewChild,
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { unique } from 'shared/unique';
+
+export const OPTION_VALUE_INPUT_VALUE_ACCESSOR: Provider = {
+    provide: NG_VALUE_ACCESSOR,
+    useExisting: forwardRef(() => OptionValueInputComponent),
+    multi: true,
+};
+
+@Component({
+    selector: 'vdr-option-value-input',
+    templateUrl: './option-value-input.component.html',
+    styleUrls: ['./option-value-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [OPTION_VALUE_INPUT_VALUE_ACCESSOR],
+})
+export class OptionValueInputComponent implements ControlValueAccessor {
+    @Input() groupName = '';
+    @ViewChild('textArea', { static: true }) textArea: ElementRef<HTMLTextAreaElement>;
+    options: string[];
+    disabled = false;
+    input = '';
+    isFocussed = false;
+    lastSelected = false;
+    onChangeFn: (value: any) => void;
+    onTouchFn: (value: any) => void;
+
+    constructor(private changeDetector: ChangeDetectorRef) {}
+
+    registerOnChange(fn: any): void {
+        this.onChangeFn = fn;
+    }
+
+    registerOnTouched(fn: any): void {
+        this.onTouchFn = fn;
+    }
+
+    setDisabledState(isDisabled: boolean): void {
+        this.disabled = isDisabled;
+        this.changeDetector.markForCheck();
+    }
+
+    writeValue(obj: any): void {
+        this.options = obj || [];
+    }
+
+    focus() {
+        this.textArea.nativeElement.focus();
+    }
+
+    removeOption(option: string) {
+        this.options = this.options.filter(o => o !== option);
+        this.onChangeFn(this.options);
+    }
+
+    handleKey(event: KeyboardEvent) {
+        switch (event.key) {
+            case ',':
+            case 'Enter':
+                this.options = unique([...this.options, ...this.parseInputIntoOptions(this.input)]);
+                this.input = '';
+                this.onChangeFn(this.options);
+                event.preventDefault();
+                break;
+            case 'Backspace':
+                if (this.lastSelected) {
+                    this.removeLastOption();
+                    this.lastSelected = false;
+                } else if (this.input === '') {
+                    this.lastSelected = true;
+                }
+                break;
+            default:
+                this.lastSelected = false;
+        }
+    }
+
+    private parseInputIntoOptions(input: string): string[] {
+        return input
+            .split(/[,\n]/)
+            .map(s => s.trim())
+            .filter(s => s !== '');
+    }
+
+    private removeLastOption() {
+        this.options = this.options.slice(0, this.options.length - 1);
+    }
+}

+ 29 - 28
admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -20,9 +20,9 @@
     <vdr-ab-right>
         <button
             class="btn btn-primary"
-            *ngIf="(isNew$ | async); else updateButton"
+            *ngIf="isNew$ | async; else updateButton"
             (click)="create()"
-            [disabled]="detailForm.invalid || detailForm.pristine"
+            [disabled]="detailForm.invalid || detailForm.pristine || !variantsToCreateAreValid()"
         >
             {{ 'common.create' | translate }}
         </button>
@@ -40,7 +40,7 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<form class="form" [formGroup]="detailForm" *ngIf="(product$ | async) as product">
+<form class="form" [formGroup]="detailForm" *ngIf="product$ | async as product">
     <clr-tabs>
         <clr-tab>
             <button clrTabLink (click)="navigateToTab('details')">
@@ -83,7 +83,7 @@
 
                             <div class="facets">
                                 <vdr-facet-value-chip
-                                    *ngFor="let facetValue of (facetValues$ | async)"
+                                    *ngFor="let facetValue of facetValues$ | async"
                                     [facetValue]="facetValue"
                                     (remove)="removeProductFacetValue(facetValue.id)"
                                 ></vdr-facet-value-chip>
@@ -102,14 +102,21 @@
                         ></vdr-product-assets>
                     </div>
                 </div>
+
+                <div *ngIf="isNew$ | async">
+                    <h4>{{ 'catalog.product-variants' | translate }}</h4>
+                    <vdr-generate-product-variants
+                        (variantsChange)="createVariantsConfig = $event"
+                    ></vdr-generate-product-variants>
+                </div>
             </clr-tab-content>
         </clr-tab>
-        <clr-tab>
+        <clr-tab *ngIf="!(isNew$ | async)">
             <button clrTabLink (click)="navigateToTab('variants')">
                 {{ 'catalog.product-variants' | translate }}
             </button>
             <clr-tab-content *clrIfActive="(activeTab$ | async) === 'variants'">
-                <section class="form-block" *ngIf="!(isNew$ | async)">
+                <section class="form-block">
                     <div class="view-mode">
                         <div class="btn-group btn-sm">
                             <button
@@ -130,29 +137,23 @@
                             </button>
                         </div>
                     </div>
-                    <vdr-generate-product-variants
-                        *ngIf="(variants$ | async)?.length === 0; else variants"
-                        [product]="product"
-                    ></vdr-generate-product-variants>
 
-                    <ng-template #variants>
-                        <vdr-product-variants-table
-                            *ngIf="variantDisplayMode === 'table'"
-                            [variants]="variants$ | async"
-                            [optionGroups]="product.optionGroups"
-                            [productVariantsFormArray]="detailForm.get('variants')"
-                        ></vdr-product-variants-table>
-                        <vdr-product-variants-list
-                            *ngIf="variantDisplayMode === 'card'"
-                            [variants]="variants$ | async"
-                            [facets]="facets$ | async"
-                            [productVariantsFormArray]="detailForm.get('variants')"
-                            [taxCategories]="taxCategories$ | async"
-                            (assetChange)="variantAssetChange($event)"
-                            (selectionChange)="selectedVariantIds = $event"
-                            (selectFacetValueClick)="selectVariantFacetValue($event)"
-                        ></vdr-product-variants-list>
-                    </ng-template>
+                    <vdr-product-variants-table
+                        *ngIf="variantDisplayMode === 'table'"
+                        [variants]="variants$ | async"
+                        [optionGroups]="product.optionGroups"
+                        [productVariantsFormArray]="detailForm.get('variants')"
+                    ></vdr-product-variants-table>
+                    <vdr-product-variants-list
+                        *ngIf="variantDisplayMode === 'card'"
+                        [variants]="variants$ | async"
+                        [facets]="facets$ | async"
+                        [productVariantsFormArray]="detailForm.get('variants')"
+                        [taxCategories]="taxCategories$ | async"
+                        (assetChange)="variantAssetChange($event)"
+                        (selectionChange)="selectedVariantIds = $event"
+                        (selectFacetValueClick)="selectVariantFacetValue($event)"
+                    ></vdr-product-variants-list>
                 </section>
             </clr-tab-content>
         </clr-tab>

+ 97 - 4
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -2,7 +2,7 @@ import { Location } from '@angular/common';
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { BehaviorSubject, combineLatest, forkJoin, merge, Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, forkJoin, merge, Observable, of } from 'rxjs';
 import { distinctUntilChanged, map, mergeMap, shareReplay, skip, take, withLatestFrom } from 'rxjs/operators';
 import { normalizeString } from 'shared/normalize-string';
 import { CustomFieldConfig } from 'shared/shared-types';
@@ -13,6 +13,7 @@ import { IGNORE_CAN_DEACTIVATE_GUARD } from 'src/app/shared/providers/routing/ca
 import { BaseDetailComponent } from '../../../common/base-detail.component';
 import {
     CreateProductInput,
+    CreateProductVariantInput,
     FacetWithValues,
     LanguageCode,
     ProductWithVariants,
@@ -30,6 +31,7 @@ import { DataService } from '../../../data/providers/data.service';
 import { ServerConfigService } from '../../../data/server-config';
 import { ModalService } from '../../../shared/providers/modal/modal.service';
 import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
+import { CreateProductVariantsConfig } from '../generate-product-variants/generate-product-variants.component';
 import { VariantAssetChange } from '../product-variants-list/product-variants-list.component';
 
 export type TabName = 'details' | 'variants';
@@ -72,6 +74,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     facets$ = new BehaviorSubject<FacetWithValues.Fragment[]>([]);
     selectedVariantIds: string[] = [];
     variantDisplayMode: 'card' | 'table' = 'card';
+    createVariantsConfig: CreateProductVariantsConfig = { groups: [], variants: [] };
 
     constructor(
         route: ActivatedRoute,
@@ -228,6 +231,15 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
             });
     }
 
+    variantsToCreateAreValid(): boolean {
+        return (
+            0 < this.createVariantsConfig.variants.length &&
+            this.createVariantsConfig.variants.every(v => {
+                return v.sku !== '';
+            })
+        );
+    }
+
     private displayFacetValueModal(): Observable<string[] | undefined> {
         let skipValue = 0;
         if (this.facets$.value.length === 0) {
@@ -265,20 +277,101 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                         productGroup as FormGroup,
                         languageCode,
                     ) as CreateProductInput;
-                    return this.dataService.product.createProduct(newProduct);
+                    const createProduct$ = this.dataService.product.createProduct(newProduct);
+
+                    const createOptionGroups$ = this.createVariantsConfig.groups.length
+                        ? forkJoin(
+                              this.createVariantsConfig.groups.map(c => {
+                                  return this.dataService.product.createProductOptionGroups({
+                                      code: normalizeString(c.name, '-'),
+                                      translations: [{ languageCode, name: c.name }],
+                                      options: c.values.map(v => ({
+                                          code: normalizeString(v, '-'),
+                                          translations: [{ languageCode, name: v }],
+                                      })),
+                                  });
+                              }),
+                          )
+                        : of([]);
+
+                    return forkJoin(createProduct$, createOptionGroups$).pipe(
+                        mergeMap(([{ createProduct }, createOptionGroups]) => {
+                            const optionGroups = createOptionGroups.map(g => g.createProductOptionGroup);
+                            const addOptionsToProduct$ = optionGroups.length
+                                ? forkJoin(
+                                      optionGroups.map(optionGroup => {
+                                          return this.dataService.product.addOptionGroupToProduct({
+                                              productId: createProduct.id,
+                                              optionGroupId: optionGroup.id,
+                                          });
+                                      }),
+                                  )
+                                : of([]);
+                            return addOptionsToProduct$.pipe(
+                                map(() => {
+                                    return { createProduct, optionGroups, languageCode };
+                                }),
+                            );
+                        }),
+                    );
+                }),
+                mergeMap(({ createProduct, optionGroups, languageCode }) => {
+                    const variants: CreateProductVariantInput[] = this.createVariantsConfig.variants.map(
+                        v => {
+                            const optionIds = optionGroups.length
+                                ? v.optionValues.map((optionName, index) => {
+                                      const option = optionGroups[index].options.find(
+                                          o => o.name === optionName,
+                                      );
+                                      if (!option) {
+                                          throw new Error(
+                                              `Could not find a matching ProductOption "${optionName}" when creating variant`,
+                                          );
+                                      }
+                                      return option.id;
+                                  })
+                                : [];
+                            const name = optionGroups.length
+                                ? `${createProduct.name} ${v.optionValues.join(' ')}`
+                                : createProduct.name;
+                            return {
+                                productId: createProduct.id,
+                                price: v.price,
+                                sku: v.sku,
+                                stockOnHand: v.stock,
+                                translations: [
+                                    {
+                                        languageCode,
+                                        name,
+                                    },
+                                ],
+                                optionIds,
+                            };
+                        },
+                    );
+                    return this.dataService.product
+                        .createProductVariants(variants)
+                        .pipe(
+                            map(({ createProductVariants }) => ({
+                                createProductVariants,
+                                productId: createProduct.id,
+                            })),
+                        );
                 }),
             )
             .subscribe(
-                data => {
+                ({ createProductVariants, productId }) => {
                     this.notificationService.success(_('common.notify-create-success'), {
                         entity: 'Product',
                     });
                     this.assetChanges = {};
                     this.variantAssetChanges = {};
                     this.detailForm.markAsPristine();
-                    this.router.navigate(['../', data.createProduct.id], { relativeTo: this.route });
+                    this.router.navigate(['../', productId], { relativeTo: this.route });
                 },
                 err => {
+                    // tslint:disable-next-line:no-console
+                    console.error(err);
                     this.notificationService.error(_('common.notify-create-error'), {
                         entity: 'Product',
                     });

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

@@ -1,55 +0,0 @@
-<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>
-        <vdr-form-field [label]="'catalog.default-price'">
-            <vdr-currency-input [(ngModel)]="defaultPrice"></vdr-currency-input>
-        </vdr-form-field>
-        <vdr-form-field [label]="'catalog.default-sku'">
-            <input type="text" [(ngModel)]="defaultSku" />
-        </vdr-form-field>
-        <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


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

@@ -1,103 +0,0 @@
-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 { ProductOptionGroup, ProductWithVariants } from '../../../common/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 { 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.Fragment;
-    @ViewChild('wizard', { static: true }) wizard: ClrWizard;
-    @ViewChild('createOptionGroupForm', { static: true })
-    createOptionGroupForm: CreateOptionGroupFormComponent;
-    @ViewChild('selectOptionGroup', { static: true }) selectOptionGroup: SelectOptionGroupComponent;
-    selectedOptionGroups: Array<Partial<ProductOptionGroup.Fragment>> = [];
-    productVariantPreviewList: string[] = [];
-    defaultPrice = 0;
-    defaultSku = '';
-
-    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<{ defaultPrice: number; defaultSku: string }> {
-        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(() => ({
-                defaultPrice: this.defaultPrice,
-                defaultSku: this.defaultSku,
-            })),
-        );
-    }
-
-    createOptionGroup() {
-        this.createOptionGroupForm.createOptionGroup().subscribe(data => {
-            this.toggleSelectedGroup(data.createProductOptionGroup);
-            this.notificationService.success(_('common.notify-create-success'), { entity: 'OptionGroup' });
-            this.createOptionGroupForm.resetForm();
-        });
-    }
-
-    toggleSelectedGroup(optionGroup: ProductOptionGroup.Fragment) {
-        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(' ')}`;
-        });
-    }
-}

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

@@ -1,10 +0,0 @@
-<ng-template vdrDialogTitle>{{ 'catalog.select-option-group' | translate }}</ng-template>
-
-<vdr-select-option-group
-    [selectedGroups]="existingOptionGroups"
-    (selectGroup)="selectGroup($event)"
-></vdr-select-option-group>
-
-<ng-template vdrDialogButtons>
-    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
-</ng-template>

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


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

@@ -1,23 +0,0 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
-
-import { ProductOptionGroup } from '../../../common/generated-types';
-import { Dialog } from '../../../shared/providers/modal/modal.service';
-
-@Component({
-    selector: 'vdr-select-option-group-dialog',
-    templateUrl: './select-option-group-dialog.component.html',
-    styleUrls: ['./select-option-group-dialog.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class SelectOptionGroupDialogComponent implements Dialog<ProductOptionGroup.Fragment> {
-    existingOptionGroups: Array<Partial<ProductOptionGroup.Fragment>>;
-    resolveWith: (result?: ProductOptionGroup.Fragment) => void;
-
-    selectGroup(group: ProductOptionGroup.Fragment) {
-        this.resolveWith(group);
-    }
-
-    cancel() {
-        this.resolveWith();
-    }
-}

+ 0 - 43
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.html

@@ -1,43 +0,0 @@
-<input
-    type="text"
-    class="filter-input"
-    [placeholder]="'catalog.filter-by-group-name' | translate"
-    [formControl]="filterInput"
-/>
-<div class="group-list">
-    <div class="group" *ngFor="let group of (optionGroups$ | async)">
-        <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">
-            <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>

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

@@ -1,42 +0,0 @@
-@import "variables";
-
-.filter-input {
-    width: 100%;
-}
-
-.group-list {
-    margin-top: 24px;
-    height: 400px;
-    max-height: 60vh;
-    overflow: auto;
-}
-
-vdr-select-toggle {
-    margin-right: 12px;
-}
-
-.group {
-    display: flex;
-    padding: 6px 12px;
-    border-bottom: 1px solid $color-grey-200;
-
-    .name-code {
-        flex: 1;
-    }
-
-    .code {
-        color: $color-grey-400;
-    }
-
-    .options {
-        text-align: right;
-    }
-}
-
-.selected-groups {
-    padding: 12px;
-}
-
-.full-options-list {
-    text-align: left;
-}

+ 0 - 76
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.ts

@@ -1,76 +0,0 @@
-import {
-    ChangeDetectionStrategy,
-    Component,
-    EventEmitter,
-    Input,
-    OnChanges,
-    OnDestroy,
-    OnInit,
-    Output,
-} from '@angular/core';
-import { FormControl } from '@angular/forms';
-import { Observable, Subject } from 'rxjs';
-import { debounceTime, map, takeUntil } from 'rxjs/operators';
-import { DeepPartial } from 'shared/shared-types';
-
-import { GetProductOptionGroups, ProductOptionGroup } from '../../../common/generated-types';
-import { DataService } from '../../../data/providers/data.service';
-import { QueryResult } from '../../../data/query-result';
-
-@Component({
-    selector: 'vdr-select-option-group',
-    templateUrl: './select-option-group.component.html',
-    styleUrls: ['./select-option-group.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class SelectOptionGroupComponent implements OnInit, OnChanges, OnDestroy {
-    @Input() selectedGroups: ProductOptionGroup[];
-    @Output() selectGroup = new EventEmitter<ProductOptionGroup>();
-    optionGroups$: Observable<Array<DeepPartial<ProductOptionGroup>>>;
-    filterInput = new FormControl();
-    optionGroupsQuery: QueryResult<GetProductOptionGroups.Query, GetProductOptionGroups.Variables>;
-    truncateOptionsTo = 4;
-    private inputChange$ = new Subject<ProductOptionGroup[]>();
-    private destroy$ = new Subject<void>();
-
-    constructor(private dataService: DataService) {}
-
-    ngOnInit() {
-        this.optionGroupsQuery = this.dataService.product.getProductOptionGroups();
-        this.optionGroups$ = this.optionGroupsQuery.stream$.pipe(map(data => data.productOptionGroups));
-
-        this.filterInput.valueChanges
-            .pipe(
-                debounceTime(300),
-                takeUntil(this.destroy$),
-            )
-            .subscribe(filterTerm => {
-                this.optionGroupsQuery.ref.refetch({ filterTerm });
-            });
-    }
-
-    ngOnChanges() {
-        this.inputChange$.next(this.selectedGroups);
-    }
-
-    ngOnDestroy() {
-        this.destroy$.next();
-        this.destroy$.complete();
-    }
-
-    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);
-    }
-}

+ 37 - 29
admin-ui/src/app/common/generated-types.ts

@@ -481,11 +481,12 @@ export type CreateProductOptionInput = {
 };
 
 export type CreateProductVariantInput = {
+  productId: Scalars['ID'],
   translations: Array<ProductVariantTranslationInput>,
   facetValueIds?: Maybe<Array<Scalars['ID']>>,
   sku: Scalars['String'],
   price?: Maybe<Scalars['Int']>,
-  taxCategoryId: Scalars['ID'],
+  taxCategoryId?: Maybe<Scalars['ID']>,
   optionIds?: Maybe<Array<Scalars['ID']>>,
   featuredAssetId?: Maybe<Scalars['ID']>,
   assetIds?: Maybe<Array<Scalars['ID']>>,
@@ -494,6 +495,12 @@ export type CreateProductVariantInput = {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type CreateProductVariantOptionInput = {
+  optionGroupId: Scalars['ID'],
+  code: Scalars['String'],
+  translations: Array<ProductOptionTranslationInput>,
+};
+
 export type CreatePromotionInput = {
   name: Scalars['String'],
   enabled: Scalars['Boolean'],
@@ -1540,12 +1547,12 @@ export type Mutation = {
   assignRoleToAdministrator: Administrator,
   /** Create a new Asset */
   createAssets: Array<Asset>,
-  login: LoginResult,
-  logout: Scalars['Boolean'],
   /** Create a new Channel */
   createChannel: Channel,
   /** Update an existing Channel */
   updateChannel: Channel,
+  login: LoginResult,
+  logout: Scalars['Boolean'],
   /** Create a new Collection */
   createCollection: Collection,
   /** Update an existing Collection */
@@ -1616,9 +1623,11 @@ export type Mutation = {
   /** Remove an OptionGroup from a Product */
   removeOptionGroupFromProduct: Product,
   /** Create a set of ProductVariants based on the OptionGroups assigned to the given Product */
-  generateVariantsForProduct: Product,
+  createProductVariants: Array<Maybe<ProductVariant>>,
   /** Update existing ProductVariants */
   updateProductVariants: Array<Maybe<ProductVariant>>,
+  /** Delete a ProductVariant */
+  deleteProductVariant: DeletionResponse,
   createPromotion: Promotion,
   updatePromotion: Promotion,
   deletePromotion: DeletionResponse,
@@ -1677,13 +1686,6 @@ export type MutationCreateAssetsArgs = {
 };
 
 
-export type MutationLoginArgs = {
-  username: Scalars['String'],
-  password: Scalars['String'],
-  rememberMe?: Maybe<Scalars['Boolean']>
-};
-
-
 export type MutationCreateChannelArgs = {
   input: CreateChannelInput
 };
@@ -1694,6 +1696,13 @@ export type MutationUpdateChannelArgs = {
 };
 
 
+export type MutationLoginArgs = {
+  username: Scalars['String'],
+  password: Scalars['String'],
+  rememberMe?: Maybe<Scalars['Boolean']>
+};
+
+
 export type MutationCreateCollectionArgs = {
   input: CreateCollectionInput
 };
@@ -1892,11 +1901,8 @@ export type MutationRemoveOptionGroupFromProductArgs = {
 };
 
 
-export type MutationGenerateVariantsForProductArgs = {
-  productId: Scalars['ID'],
-  defaultTaxCategoryId?: Maybe<Scalars['ID']>,
-  defaultPrice?: Maybe<Scalars['Int']>,
-  defaultSku?: Maybe<Scalars['String']>
+export type MutationCreateProductVariantsArgs = {
+  input: Array<CreateProductVariantInput>
 };
 
 
@@ -1905,6 +1911,11 @@ export type MutationUpdateProductVariantsArgs = {
 };
 
 
+export type MutationDeleteProductVariantArgs = {
+  id: Scalars['ID']
+};
+
+
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput
 };
@@ -2291,6 +2302,7 @@ export type ProductOption = Node & {
   languageCode?: Maybe<LanguageCode>,
   code?: Maybe<Scalars['String']>,
   name?: Maybe<Scalars['String']>,
+  groupId: Scalars['ID'],
   translations: Array<ProductOptionTranslation>,
   customFields?: Maybe<Scalars['JSON']>,
 };
@@ -2501,10 +2513,10 @@ export type Query = {
   administrator?: Maybe<Administrator>,
   assets: AssetList,
   asset?: Maybe<Asset>,
-  me?: Maybe<CurrentUser>,
   channels: Array<Channel>,
   channel?: Maybe<Channel>,
   activeChannel: Channel,
+  me?: Maybe<CurrentUser>,
   collections: CollectionList,
   collection?: Maybe<Collection>,
   collectionFilters: Array<ConfigurableOperation>,
@@ -3617,7 +3629,7 @@ export type AddNoteToOrderMutationVariables = {
 };
 
 
-export type AddNoteToOrderMutation = ({ __typename?: 'Mutation' } & { addNoteToOrder: ({ __typename?: 'Order' } & Pick<Order, 'id' | 'updatedAt'> & { history: ({ __typename?: 'HistoryEntryList' } & Pick<HistoryEntryList, 'totalItems'>) }) });
+export type AddNoteToOrderMutation = ({ __typename?: 'Mutation' } & { addNoteToOrder: ({ __typename?: 'Order' } & Pick<Order, 'id'>) });
 
 export type AssetFragment = ({ __typename?: 'Asset' } & Pick<Asset, 'id' | 'createdAt' | 'name' | 'fileSize' | 'mimeType' | 'type' | 'preview' | 'source'>);
 
@@ -3648,15 +3660,12 @@ export type DeleteProductMutationVariables = {
 
 export type DeleteProductMutation = ({ __typename?: 'Mutation' } & { deleteProduct: ({ __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>) });
 
-export type GenerateProductVariantsMutationVariables = {
-  productId: Scalars['ID'],
-  defaultTaxCategoryId?: Maybe<Scalars['ID']>,
-  defaultPrice?: Maybe<Scalars['Int']>,
-  defaultSku?: Maybe<Scalars['String']>
+export type CreateProductVariantsMutationVariables = {
+  input: Array<CreateProductVariantInput>
 };
 
 
-export type GenerateProductVariantsMutation = ({ __typename?: 'Mutation' } & { generateVariantsForProduct: ({ __typename?: 'Product' } & ProductWithVariantsFragment) });
+export type CreateProductVariantsMutation = ({ __typename?: 'Mutation' } & { createProductVariants: Array<Maybe<({ __typename?: 'ProductVariant' } & ProductVariantFragment)>> });
 
 export type UpdateProductVariantsMutationVariables = {
   input: Array<UpdateProductVariantInput>
@@ -4441,7 +4450,6 @@ export namespace AddNoteToOrder {
   export type Variables = AddNoteToOrderMutationVariables;
   export type Mutation = AddNoteToOrderMutation;
   export type AddNoteToOrder = AddNoteToOrderMutation['addNoteToOrder'];
-  export type History = AddNoteToOrderMutation['addNoteToOrder']['history'];
 }
 
 export namespace Asset {
@@ -4496,10 +4504,10 @@ export namespace DeleteProduct {
   export type DeleteProduct = DeleteProductMutation['deleteProduct'];
 }
 
-export namespace GenerateProductVariants {
-  export type Variables = GenerateProductVariantsMutationVariables;
-  export type Mutation = GenerateProductVariantsMutation;
-  export type GenerateVariantsForProduct = ProductWithVariantsFragment;
+export namespace CreateProductVariants {
+  export type Variables = CreateProductVariantsMutationVariables;
+  export type Mutation = CreateProductVariantsMutation;
+  export type CreateProductVariants = ProductVariantFragment;
 }
 
 export namespace UpdateProductVariants {

+ 5 - 15
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -156,23 +156,13 @@ export const DELETE_PRODUCT = gql`
     }
 `;
 
-export const GENERATE_PRODUCT_VARIANTS = gql`
-    mutation GenerateProductVariants(
-        $productId: ID!
-        $defaultTaxCategoryId: ID
-        $defaultPrice: Int
-        $defaultSku: String
-    ) {
-        generateVariantsForProduct(
-            productId: $productId
-            defaultTaxCategoryId: $defaultTaxCategoryId
-            defaultPrice: $defaultPrice
-            defaultSku: $defaultSku
-        ) {
-            ...ProductWithVariants
+export const CREATE_PRODUCT_VARIANTS = gql`
+    mutation CreateProductVariants($input: [CreateProductVariantInput!]!) {
+        createProductVariants(input: $input) {
+            ...ProductVariant
         }
     }
-    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
+    ${PRODUCT_VARIANT_FRAGMENT}
 `;
 
 export const UPDATE_PRODUCT_VARIANTS = gql`

+ 10 - 7
admin-ui/src/app/data/providers/product-data.service.ts

@@ -7,8 +7,9 @@ import {
     CreateProductInput,
     CreateProductOptionGroup,
     CreateProductOptionGroupInput,
+    CreateProductVariantInput,
+    CreateProductVariants,
     DeleteProduct,
-    GenerateProductVariants,
     GetAssetList,
     GetProductList,
     GetProductOptionGroups,
@@ -28,8 +29,8 @@ import {
     CREATE_ASSETS,
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
+    CREATE_PRODUCT_VARIANTS,
     DELETE_PRODUCT,
-    GENERATE_PRODUCT_VARIANTS,
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
     GET_PRODUCT_OPTION_GROUPS,
@@ -121,11 +122,13 @@ export class ProductDataService {
         });
     }
 
-    generateProductVariants(productId: string, defaultPrice?: number, defaultSku?: string) {
-        return this.baseDataService.mutate<
-            GenerateProductVariants.Mutation,
-            GenerateProductVariants.Variables
-        >(GENERATE_PRODUCT_VARIANTS, { productId, defaultPrice, defaultSku });
+    createProductVariants(input: CreateProductVariantInput[]) {
+        return this.baseDataService.mutate<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
+            CREATE_PRODUCT_VARIANTS,
+            {
+                input,
+            },
+        );
     }
 
     updateProductVariants(variants: UpdateProductVariantInput[]) {

+ 6 - 19
admin-ui/src/i18n-messages/en.json

@@ -26,28 +26,22 @@
     "add-asset-with-count": "Add {count, plural, 0 {assets} one {1 asset} other {{count} assets}}",
     "add-facet-value": "Add facet value",
     "add-facets": "Add facets",
+    "add-option": "Add option",
     "assets-selected-count": "{ count } assets selected",
     "collection-contents": "Collection contents",
     "confirm-delete-country": "Delete country?",
     "confirm-delete-facet": "Delete facet?",
     "confirm-delete-facet-value": "Delete facet value?",
     "confirm-delete-product": "Delete product?",
-    "confirm-generate-product-variants": "Click 'Finish' to generate {count} product variants.",
-    "create-group": "Create option group",
     "create-new-collection": "Create new collection",
     "create-new-facet": "Create new facet",
-    "create-new-option-group": "Create new option group",
     "create-new-product": "New product",
     "display-variant-cards": "View details",
     "display-variant-table": "View as table",
     "drop-files-to-upload": "Drop files to upload",
     "facet-values": "Facet values",
-    "filter-by-group-name": "Filter by group name",
     "filter-by-name": "Filter by name",
     "filters": "Filters",
-    "generate-product-variants": "Generate product variants",
-    "generate-variants-default-only": "This product does not have options",
-    "generate-variants-with-options": "This product has options",
     "group-by-product": "Group by product",
     "height": "Height",
     "move-down": "Move down",
@@ -57,10 +51,8 @@
     "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",
-    "option-group-options-label": "Options",
-    "option-group-options-tooltip": "Enter each option on a new line in the default language ({ defaultLanguage })",
+    "option": "Option",
+    "option-values": "Option values",
     "options": "Options",
     "original-asset-size": "Source size",
     "preview": "Preview",
@@ -78,12 +70,11 @@
     "reindex-successful": "Indexed {count, plural, one {product variant} other {{count} product variants}} in {time}ms",
     "reindexing": "Rebuilding search index",
     "remove-asset": "Remove asset",
+    "remove-option": "Remove option",
     "search-asset-name": "Search assets by name",
     "search-for-term": "Search for term",
     "search-product-name-or-code": "Search by product name or code",
     "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",
@@ -92,9 +83,9 @@
     "tax-category": "Tax category",
     "taxes": "Taxes",
     "track-inventory": "Track inventory",
-    "truncated-options-count": "{count} further {count, plural, one {option} other {options}}",
     "upload-assets": "Upload assets",
     "values": "Values",
+    "variant": "Variant",
     "view-contents": "View contents",
     "visibility": "Visibility",
     "width": "Width"
@@ -103,11 +94,9 @@
     "ID": "ID",
     "actions": "Actions",
     "available-languages": "Available languages",
-    "back": "Back",
     "cancel": "Cancel",
     "cancel-navigation": "Cancel navigation",
     "code": "Code",
-    "confirm": "Confirm",
     "confirm-navigation": "Confirm navigation",
     "create": "Create",
     "created-at": "Created at",
@@ -120,7 +109,6 @@
     "edit": "Edit",
     "edit-field": "Edit field",
     "enabled": "Enabled",
-    "finish": "Finish",
     "guest": "Guest",
     "items-per-page-option": "{ count } per page",
     "jobs-in-progress": "{ count } {count, plural, one {job} other {jobs}} in progress",
@@ -129,7 +117,6 @@
     "login": "Log in",
     "more": "More...",
     "name": "Name",
-    "next": "Next",
     "no-results": "No results",
     "notify-create-error": "An error occurred, could not create { entity }",
     "notify-create-success": "Created new { entity }",
@@ -526,4 +513,4 @@
     "update": "Update",
     "zone": "Zone"
   }
-}
+}