Browse Source

Merge pull request #127 from vendure-ecommerce/rework-options-variants

Rework options variants
Michael Bromley 6 years ago
parent
commit
d2b2b18841
61 changed files with 1309 additions and 927 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
  30. 1 0
      packages/common/src/generated-shop-types.ts
  31. 56 44
      packages/common/src/generated-types.ts
  32. 5 0
      packages/common/src/shared-utils.spec.ts
  33. 3 0
      packages/common/src/shared-utils.ts
  34. 5 5
      packages/core/e2e/__snapshots__/import.e2e-spec.ts.snap
  35. 91 50
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  36. 1 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  37. 9 0
      packages/core/e2e/graphql/shared-definitions.ts
  38. 1 2
      packages/core/e2e/import.e2e-spec.ts
  39. 233 73
      packages/core/e2e/product.e2e-spec.ts
  40. 12 5
      packages/core/mock-data/create-upload-post-data.spec.ts
  41. 1 13
      packages/core/mock-data/mock-data.service.ts
  42. 8 2
      packages/core/mock-data/simple-graphql-client.ts
  43. 6 0
      packages/core/src/api/common/id-codec.ts
  44. 24 18
      packages/core/src/api/resolvers/admin/product.resolver.ts
  45. 23 1
      packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts
  46. 18 2
      packages/core/src/api/schema/admin-api/product.api.graphql
  47. 1 0
      packages/core/src/api/schema/type/product-option-group.type.graphql
  48. 2 1
      packages/core/src/data-import/providers/importer/importer.ts
  49. 5 1
      packages/core/src/entity/product-option-group/product-option-group.entity.ts
  50. 5 1
      packages/core/src/entity/product-option/product-option.entity.ts
  51. 3 3
      packages/core/src/entity/product-variant/product-variant-price.entity.ts
  52. 5 1
      packages/core/src/entity/product-variant/product-variant.entity.ts
  53. 2 2
      packages/core/src/entity/product-variant/product-variant.subscriber.ts
  54. 3 3
      packages/core/src/entity/product/product.entity.ts
  55. 4 0
      packages/core/src/i18n/messages/en.json
  56. 56 0
      packages/core/src/service/helpers/utils/samples-each.spec.ts
  57. 17 0
      packages/core/src/service/helpers/utils/samples-each.ts
  58. 136 23
      packages/core/src/service/services/product-variant.service.ts
  59. 15 4
      packages/core/src/service/services/product.service.ts
  60. 0 0
      schema-admin.json
  61. 0 0
      schema-shop.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"
   }
-}
+}

+ 1 - 0
packages/common/src/generated-shop-types.ts

@@ -1593,6 +1593,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']>;
 };

+ 56 - 44
packages/common/src/generated-types.ts

@@ -480,11 +480,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']>>,
@@ -493,6 +494,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'],
@@ -1551,14 +1558,6 @@ export type Mutation = {
   updateCollection: Collection,
   /** Move a Collection to a different parent or index */
   moveCollection: Collection,
-  /** Create a new CustomerGroup */
-  createCustomerGroup: CustomerGroup,
-  /** Update an existing CustomerGroup */
-  updateCustomerGroup: CustomerGroup,
-  /** Add Customers to a CustomerGroup */
-  addCustomersToGroup: CustomerGroup,
-  /** Remove Customers from a CustomerGroup */
-  removeCustomersFromGroup: CustomerGroup,
   /** Create a new Country */
   createCountry: Country,
   /** Update an existing Country */
@@ -1577,6 +1576,14 @@ export type Mutation = {
   updateCustomerAddress: Address,
   /** Update an existing Address */
   deleteCustomerAddress: Scalars['Boolean'],
+  /** Create a new CustomerGroup */
+  createCustomerGroup: CustomerGroup,
+  /** Update an existing CustomerGroup */
+  updateCustomerGroup: CustomerGroup,
+  /** Add Customers to a CustomerGroup */
+  addCustomersToGroup: CustomerGroup,
+  /** Remove Customers from a CustomerGroup */
+  removeCustomersFromGroup: CustomerGroup,
   /** Create a new Facet */
   createFacet: Facet,
   /** Update an existing Facet */
@@ -1615,9 +1622,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,
@@ -1703,28 +1712,6 @@ export type MutationMoveCollectionArgs = {
 };
 
 
-export type MutationCreateCustomerGroupArgs = {
-  input: CreateCustomerGroupInput
-};
-
-
-export type MutationUpdateCustomerGroupArgs = {
-  input: UpdateCustomerGroupInput
-};
-
-
-export type MutationAddCustomersToGroupArgs = {
-  customerGroupId: Scalars['ID'],
-  customerIds: Array<Scalars['ID']>
-};
-
-
-export type MutationRemoveCustomersFromGroupArgs = {
-  customerGroupId: Scalars['ID'],
-  customerIds: Array<Scalars['ID']>
-};
-
-
 export type MutationCreateCountryArgs = {
   input: CreateCountryInput
 };
@@ -1772,6 +1759,28 @@ export type MutationDeleteCustomerAddressArgs = {
 };
 
 
+export type MutationCreateCustomerGroupArgs = {
+  input: CreateCustomerGroupInput
+};
+
+
+export type MutationUpdateCustomerGroupArgs = {
+  input: UpdateCustomerGroupInput
+};
+
+
+export type MutationAddCustomersToGroupArgs = {
+  customerGroupId: Scalars['ID'],
+  customerIds: Array<Scalars['ID']>
+};
+
+
+export type MutationRemoveCustomersFromGroupArgs = {
+  customerGroupId: Scalars['ID'],
+  customerIds: Array<Scalars['ID']>
+};
+
+
 export type MutationCreateFacetArgs = {
   input: CreateFacetInput
 };
@@ -1886,11 +1895,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>
 };
 
 
@@ -1899,6 +1905,11 @@ export type MutationUpdateProductVariantsArgs = {
 };
 
 
+export type MutationDeleteProductVariantArgs = {
+  id: Scalars['ID']
+};
+
+
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput
 };
@@ -2269,6 +2280,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']>,
 };
@@ -2486,12 +2498,12 @@ export type Query = {
   collections: CollectionList,
   collection?: Maybe<Collection>,
   collectionFilters: Array<ConfigurableOperation>,
-  customerGroups: Array<CustomerGroup>,
-  customerGroup?: Maybe<CustomerGroup>,
   countries: CountryList,
   country?: Maybe<Country>,
   customers: CustomerList,
   customer?: Maybe<Customer>,
+  customerGroups: Array<CustomerGroup>,
+  customerGroup?: Maybe<CustomerGroup>,
   facets: FacetList,
   facet?: Maybe<Facet>,
   globalSettings: GlobalSettings,
@@ -2562,11 +2574,6 @@ export type QueryCollectionArgs = {
 };
 
 
-export type QueryCustomerGroupArgs = {
-  id: Scalars['ID']
-};
-
-
 export type QueryCountriesArgs = {
   options?: Maybe<CountryListOptions>
 };
@@ -2587,6 +2594,11 @@ export type QueryCustomerArgs = {
 };
 
 
+export type QueryCustomerGroupArgs = {
+  id: Scalars['ID']
+};
+
+
 export type QueryFacetsArgs = {
   languageCode?: Maybe<LanguageCode>,
   options?: Maybe<FacetListOptions>

+ 5 - 0
packages/common/src/shared-utils.spec.ts

@@ -22,4 +22,9 @@ describe('generateAllCombinations()', () => {
             ['blue', 'large'],
         ]);
     });
+
+    it('works with second array empty', () => {
+        const result = generateAllCombinations([['red', 'green', 'blue'], []]);
+        expect(result).toEqual([['red'], ['green'], ['blue']]);
+    });
 });

+ 3 - 0
packages/common/src/shared-utils.ts

@@ -34,6 +34,9 @@ export function generateAllCombinations<T>(
     k: number = 0,
     output: T[][] = [],
 ): T[][] {
+    if (k === 0) {
+        optionGroups = optionGroups.filter(g => 0 < g.length);
+    }
     if (k === optionGroups.length) {
         output.push(combination);
         return [];

+ 5 - 5
packages/core/e2e/__snapshots__/import.e2e-spec.ts.snap

@@ -350,16 +350,16 @@ Array [
     "id": "T_4",
     "name": "Artists Smock",
     "optionGroups": Array [
-      Object {
-        "code": "artists-smock-size",
-        "id": "T_3",
-        "name": "size",
-      },
       Object {
         "code": "artists-smock-colour",
         "id": "T_4",
         "name": "colour",
       },
+      Object {
+        "code": "artists-smock-size",
+        "id": "T_3",
+        "name": "size",
+      },
     ],
     "slug": "artists-smock",
     "variants": Array [

+ 91 - 50
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -480,11 +480,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']>>;
@@ -493,6 +494,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'];
@@ -1548,14 +1555,6 @@ export type Mutation = {
     updateCollection: Collection;
     /** Move a Collection to a different parent or index */
     moveCollection: Collection;
-    /** Create a new CustomerGroup */
-    createCustomerGroup: CustomerGroup;
-    /** Update an existing CustomerGroup */
-    updateCustomerGroup: CustomerGroup;
-    /** Add Customers to a CustomerGroup */
-    addCustomersToGroup: CustomerGroup;
-    /** Remove Customers from a CustomerGroup */
-    removeCustomersFromGroup: CustomerGroup;
     /** Create a new Country */
     createCountry: Country;
     /** Update an existing Country */
@@ -1574,6 +1573,14 @@ export type Mutation = {
     updateCustomerAddress: Address;
     /** Update an existing Address */
     deleteCustomerAddress: Scalars['Boolean'];
+    /** Create a new CustomerGroup */
+    createCustomerGroup: CustomerGroup;
+    /** Update an existing CustomerGroup */
+    updateCustomerGroup: CustomerGroup;
+    /** Add Customers to a CustomerGroup */
+    addCustomersToGroup: CustomerGroup;
+    /** Remove Customers from a CustomerGroup */
+    removeCustomersFromGroup: CustomerGroup;
     /** Create a new Facet */
     createFacet: Facet;
     /** Update an existing Facet */
@@ -1612,9 +1619,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;
@@ -1689,24 +1698,6 @@ export type MutationMoveCollectionArgs = {
     input: MoveCollectionInput;
 };
 
-export type MutationCreateCustomerGroupArgs = {
-    input: CreateCustomerGroupInput;
-};
-
-export type MutationUpdateCustomerGroupArgs = {
-    input: UpdateCustomerGroupInput;
-};
-
-export type MutationAddCustomersToGroupArgs = {
-    customerGroupId: Scalars['ID'];
-    customerIds: Array<Scalars['ID']>;
-};
-
-export type MutationRemoveCustomersFromGroupArgs = {
-    customerGroupId: Scalars['ID'];
-    customerIds: Array<Scalars['ID']>;
-};
-
 export type MutationCreateCountryArgs = {
     input: CreateCountryInput;
 };
@@ -1745,6 +1736,24 @@ export type MutationDeleteCustomerAddressArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationCreateCustomerGroupArgs = {
+    input: CreateCustomerGroupInput;
+};
+
+export type MutationUpdateCustomerGroupArgs = {
+    input: UpdateCustomerGroupInput;
+};
+
+export type MutationAddCustomersToGroupArgs = {
+    customerGroupId: Scalars['ID'];
+    customerIds: Array<Scalars['ID']>;
+};
+
+export type MutationRemoveCustomersFromGroupArgs = {
+    customerGroupId: Scalars['ID'];
+    customerIds: Array<Scalars['ID']>;
+};
+
 export type MutationCreateFacetArgs = {
     input: CreateFacetInput;
 };
@@ -1837,17 +1846,18 @@ export type MutationRemoveOptionGroupFromProductArgs = {
     optionGroupId: Scalars['ID'];
 };
 
-export type MutationGenerateVariantsForProductArgs = {
-    productId: Scalars['ID'];
-    defaultTaxCategoryId?: Maybe<Scalars['ID']>;
-    defaultPrice?: Maybe<Scalars['Int']>;
-    defaultSku?: Maybe<Scalars['String']>;
+export type MutationCreateProductVariantsArgs = {
+    input: Array<CreateProductVariantInput>;
 };
 
 export type MutationUpdateProductVariantsArgs = {
     input: Array<UpdateProductVariantInput>;
 };
 
+export type MutationDeleteProductVariantArgs = {
+    id: Scalars['ID'];
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -2202,6 +2212,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']>;
 };
@@ -2418,12 +2429,12 @@ export type Query = {
     collections: CollectionList;
     collection?: Maybe<Collection>;
     collectionFilters: Array<ConfigurableOperation>;
-    customerGroups: Array<CustomerGroup>;
-    customerGroup?: Maybe<CustomerGroup>;
     countries: CountryList;
     country?: Maybe<Country>;
     customers: CustomerList;
     customer?: Maybe<Customer>;
+    customerGroups: Array<CustomerGroup>;
+    customerGroup?: Maybe<CustomerGroup>;
     facets: FacetList;
     facet?: Maybe<Facet>;
     globalSettings: GlobalSettings;
@@ -2486,10 +2497,6 @@ export type QueryCollectionArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryCustomerGroupArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryCountriesArgs = {
     options?: Maybe<CountryListOptions>;
 };
@@ -2506,6 +2513,10 @@ export type QueryCustomerArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryCustomerGroupArgs = {
+    id: Scalars['ID'];
+};
+
 export type QueryFacetsArgs = {
     languageCode?: Maybe<LanguageCode>;
     options?: Maybe<FacetListOptions>;
@@ -3807,6 +3818,14 @@ export type GetProductListQuery = { __typename?: 'Query' } & {
         };
 };
 
+export type CreateProductVariantsMutationVariables = {
+    input: Array<CreateProductVariantInput>;
+};
+
+export type CreateProductVariantsMutation = { __typename?: 'Mutation' } & {
+    createProductVariants: Array<Maybe<{ __typename?: 'ProductVariant' } & ProductVariantFragment>>;
+};
+
 export type UpdateProductVariantsMutationVariables = {
     input: Array<UpdateProductVariantInput>;
 };
@@ -4215,15 +4234,24 @@ export type RemoveOptionGroupFromProductMutation = { __typename?: 'Mutation' } &
         };
 };
 
-export type GenerateProductVariantsMutationVariables = {
-    productId: Scalars['ID'];
-    defaultTaxCategoryId?: Maybe<Scalars['ID']>;
-    defaultPrice?: Maybe<Scalars['Int']>;
-    defaultSku?: Maybe<Scalars['String']>;
+export type GetOptionGroupQueryVariables = {
+    id: Scalars['ID'];
 };
 
-export type GenerateProductVariantsMutation = { __typename?: 'Mutation' } & {
-    generateVariantsForProduct: { __typename?: 'Product' } & ProductWithVariantsFragment;
+export type GetOptionGroupQuery = { __typename?: 'Query' } & {
+    productOptionGroup: Maybe<
+        { __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'code'> & {
+                options: Array<{ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'code'>>;
+            }
+    >;
+};
+
+export type DeleteProductVariantMutationVariables = {
+    id: Scalars['ID'];
+};
+
+export type DeleteProductVariantMutation = { __typename?: 'Mutation' } & {
+    deleteProductVariant: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>;
 };
 
 export type DeletePromotionMutationVariables = {
@@ -4918,6 +4946,12 @@ export namespace GetProductList {
     >;
 }
 
+export namespace CreateProductVariants {
+    export type Variables = CreateProductVariantsMutationVariables;
+    export type Mutation = CreateProductVariantsMutation;
+    export type CreateProductVariants = ProductVariantFragment;
+}
+
 export namespace UpdateProductVariants {
     export type Variables = UpdateProductVariantsMutationVariables;
     export type Mutation = UpdateProductVariantsMutation;
@@ -5219,10 +5253,17 @@ export namespace RemoveOptionGroupFromProduct {
     >;
 }
 
-export namespace GenerateProductVariants {
-    export type Variables = GenerateProductVariantsMutationVariables;
-    export type Mutation = GenerateProductVariantsMutation;
-    export type GenerateVariantsForProduct = ProductWithVariantsFragment;
+export namespace GetOptionGroup {
+    export type Variables = GetOptionGroupQueryVariables;
+    export type Query = GetOptionGroupQuery;
+    export type ProductOptionGroup = NonNullable<GetOptionGroupQuery['productOptionGroup']>;
+    export type Options = NonNullable<(NonNullable<GetOptionGroupQuery['productOptionGroup']>)['options'][0]>;
+}
+
+export namespace DeleteProductVariant {
+    export type Variables = DeleteProductVariantMutationVariables;
+    export type Mutation = DeleteProductVariantMutation;
+    export type DeleteProductVariant = DeleteProductVariantMutation['deleteProductVariant'];
 }
 
 export namespace DeletePromotion {

+ 1 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1593,6 +1593,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']>;
 };

+ 9 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -70,6 +70,15 @@ export const GET_PRODUCT_LIST = gql`
     }
 `;
 
+export const CREATE_PRODUCT_VARIANTS = gql`
+    mutation CreateProductVariants($input: [CreateProductVariantInput!]!) {
+        createProductVariants(input: $input) {
+            ...ProductVariant
+        }
+    }
+    ${PRODUCT_VARIANT_FRAGMENT}
+`;
+
 export const UPDATE_PRODUCT_VARIANTS = gql`
     mutation UpdateProductVariants($input: [UpdateProductVariantInput!]!) {
         updateProductVariants(input: $input) {

+ 1 - 2
packages/core/e2e/import.e2e-spec.ts

@@ -128,6 +128,5 @@ describe('Import resolver', () => {
 
         expect(productResult.products.totalItems).toBe(4);
         expect(productResult.products.items).toMatchSnapshot();
-        return;
-    }, 10000);
+    }, 20000);
 });

+ 233 - 73
packages/core/e2e/product.e2e-spec.ts

@@ -1,16 +1,19 @@
 import { omit } from '@vendure/common/lib/omit';
+import { pick } from '@vendure/common/lib/pick';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import gql from 'graphql-tag';
 import path from 'path';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { PRODUCT_WITH_VARIANTS_FRAGMENT } from './graphql/fragments';
 import {
     AddOptionGroupToProduct,
     CreateProduct,
+    CreateProductVariants,
     DeleteProduct,
+    DeleteProductVariant,
     DeletionResult,
-    GenerateProductVariants,
     GetAssetList,
+    GetOptionGroup,
     GetProductList,
     GetProductSimple,
     GetProductWithVariants,
@@ -23,6 +26,7 @@ import {
 } from './graphql/generated-e2e-admin-types';
 import {
     CREATE_PRODUCT,
+    CREATE_PRODUCT_VARIANTS,
     DELETE_PRODUCT,
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
@@ -155,6 +159,16 @@ describe('Product resolver', () => {
             }, 'Either the product id or slug must be provided'),
         );
 
+        it(
+            'throws if id and slug do not refer to the same Product',
+            assertThrowsWithMessage(async () => {
+                await client.query<GetProductSimple.Query, GetProductSimple.Variables>(GET_PRODUCT_SIMPLE, {
+                    id: 'T_2',
+                    slug: 'laptop',
+                });
+            }, 'The provided id and slug refer to different Products'),
+        );
+
         it('returns expected properties', async () => {
             const { product } = await client.query<
                 GetProductWithVariants.Query,
@@ -207,6 +221,7 @@ describe('Product resolver', () => {
 
     describe('product mutation', () => {
         let newProduct: ProductWithVariants.Fragment;
+        let newProductWithAssets: ProductWithVariants.Fragment;
 
         it('createProduct creates a new Product', async () => {
             const result = await client.query<CreateProduct.Mutation, CreateProduct.Variables>(
@@ -230,8 +245,8 @@ describe('Product resolver', () => {
                     },
                 },
             );
+            expect(result.createProduct).toMatchSnapshot();
             newProduct = result.createProduct;
-            expect(newProduct).toMatchSnapshot();
         });
 
         it('createProduct creates a new Product with assets', async () => {
@@ -260,6 +275,7 @@ describe('Product resolver', () => {
             );
             expect(result.createProduct.assets.map(a => a.id)).toEqual(assetIds);
             expect(result.createProduct.featuredAsset!.id).toBe(featuredAssetId);
+            newProductWithAssets = result.createProduct;
         });
 
         it('updateProduct updates a Product', async () => {
@@ -512,17 +528,41 @@ describe('Product resolver', () => {
         );
 
         it('removeOptionGroupFromProduct removes an option group', async () => {
+            const { addOptionGroupToProduct } = await client.query<
+                AddOptionGroupToProduct.Mutation,
+                AddOptionGroupToProduct.Variables
+            >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                optionGroupId: 'T_1',
+                productId: newProductWithAssets.id,
+            });
+            expect(addOptionGroupToProduct.optionGroups.length).toBe(1);
+
             const result = await client.query<
                 RemoveOptionGroupFromProduct.Mutation,
                 RemoveOptionGroupFromProduct.Variables
             >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
                 optionGroupId: 'T_1',
-                productId: 'T_1',
+                productId: newProductWithAssets.id,
             });
-            expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(1);
-            expect(result.removeOptionGroupFromProduct.optionGroups[0].code).toBe('laptop-ram');
+            expect(result.removeOptionGroupFromProduct.id).toBe(newProductWithAssets.id);
+            expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(0);
         });
 
+        it(
+            'removeOptionGroupFromProduct errors if the optionGroup is being used by variants',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<
+                        RemoveOptionGroupFromProduct.Mutation,
+                        RemoveOptionGroupFromProduct.Variables
+                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: 'T_3',
+                        productId: 'T_2',
+                    }),
+                `Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants`,
+            ),
+        );
+
         it(
             'removeOptionGroupFromProduct errors with an invalid productId',
             assertThrowsWithMessage(
@@ -538,56 +578,160 @@ describe('Product resolver', () => {
             ),
         );
 
+        it(
+            'removeOptionGroupFromProduct errors with an invalid optionGroupId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<
+                        RemoveOptionGroupFromProduct.Mutation,
+                        RemoveOptionGroupFromProduct.Variables
+                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: '999',
+                        productId: newProduct.id,
+                    }),
+                `No ProductOptionGroup with the id '999' could be found`,
+            ),
+        );
+
         describe('variants', () => {
-            let variants: ProductWithVariants.Variants[];
+            let variants: CreateProductVariants.CreateProductVariants[];
+            let optionGroup2: GetOptionGroup.ProductOptionGroup;
+            let optionGroup3: GetOptionGroup.ProductOptionGroup;
+
+            beforeAll(async () => {
+                await client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
+                    ADD_OPTION_GROUP_TO_PRODUCT,
+                    {
+                        optionGroupId: 'T_3',
+                        productId: newProduct.id,
+                    },
+                );
+                const result1 = await client.query<GetOptionGroup.Query, GetOptionGroup.Variables>(
+                    GET_OPTION_GROUP,
+                    { id: 'T_2' },
+                );
+                const result2 = await client.query<GetOptionGroup.Query, GetOptionGroup.Variables>(
+                    GET_OPTION_GROUP,
+                    { id: 'T_3' },
+                );
+                optionGroup2 = result1.productOptionGroup!;
+                optionGroup3 = result2.productOptionGroup!;
+            });
 
             it(
-                'generateVariantsForProduct throws with an invalid productId',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                            GENERATE_PRODUCT_VARIANTS,
-                            {
-                                productId: '999',
-                            },
-                        ),
-                    `No Product with the id '999' could be found`,
-                ),
+                'createProductVariants throws if optionIds not compatible with product',
+                assertThrowsWithMessage(async () => {
+                    await client.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
+                        CREATE_PRODUCT_VARIANTS,
+                        {
+                            input: [
+                                {
+                                    productId: newProduct.id,
+                                    sku: 'PV1',
+                                    optionIds: [],
+                                    translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
+                                },
+                            ],
+                        },
+                    );
+                }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
             );
 
             it(
-                'generateVariantsForProduct throws with an invalid defaultTaxCategoryId',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                            GENERATE_PRODUCT_VARIANTS,
-                            {
-                                productId: newProduct.id,
-                                defaultTaxCategoryId: '999',
-                            },
-                        ),
-                    `No TaxCategory with the id '999' could be found`,
-                ),
+                'createProductVariants throws if optionIds are duplicated',
+                assertThrowsWithMessage(async () => {
+                    await client.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
+                        CREATE_PRODUCT_VARIANTS,
+                        {
+                            input: [
+                                {
+                                    productId: newProduct.id,
+                                    sku: 'PV1',
+                                    optionIds: [optionGroup2.options[0].id, optionGroup2.options[1].id],
+                                    translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
+                                },
+                            ],
+                        },
+                    );
+                }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
             );
 
-            it('generateVariantsForProduct generates variants', async () => {
-                const result = await client.query<
-                    GenerateProductVariants.Mutation,
-                    GenerateProductVariants.Variables
-                >(GENERATE_PRODUCT_VARIANTS, {
-                    productId: newProduct.id,
-                    defaultPrice: 123,
-                    defaultSku: 'ABC',
+            it('createProductVariants works', async () => {
+                const { createProductVariants } = await client.query<
+                    CreateProductVariants.Mutation,
+                    CreateProductVariants.Variables
+                >(CREATE_PRODUCT_VARIANTS, {
+                    input: [
+                        {
+                            productId: newProduct.id,
+                            sku: 'PV1',
+                            optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
+                            translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
+                        },
+                    ],
                 });
-                variants = result.generateVariantsForProduct.variants;
-                expect(variants.length).toBe(2);
-                expect(variants[0].options.length).toBe(1);
-                expect(variants[1].options.length).toBe(1);
+                expect(createProductVariants[0]!.name).toBe('Variant 1');
+                expect(createProductVariants[0]!.options.map(pick(['id']))).toEqual([
+                    { id: optionGroup2.options[0].id },
+                    { id: optionGroup3.options[0].id },
+                ]);
             });
 
+            it('createProductVariants adds multiple variants at once', async () => {
+                const { createProductVariants } = await client.query<
+                    CreateProductVariants.Mutation,
+                    CreateProductVariants.Variables
+                >(CREATE_PRODUCT_VARIANTS, {
+                    input: [
+                        {
+                            productId: newProduct.id,
+                            sku: 'PV2',
+                            optionIds: [optionGroup2.options[1].id, optionGroup3.options[0].id],
+                            translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
+                        },
+                        {
+                            productId: newProduct.id,
+                            sku: 'PV3',
+                            optionIds: [optionGroup2.options[1].id, optionGroup3.options[1].id],
+                            translations: [{ languageCode: LanguageCode.en, name: 'Variant 3' }],
+                        },
+                    ],
+                });
+                expect(createProductVariants[0]!.name).toBe('Variant 2');
+                expect(createProductVariants[1]!.name).toBe('Variant 3');
+                expect(createProductVariants[0]!.options.map(pick(['id']))).toEqual([
+                    { id: optionGroup2.options[1].id },
+                    { id: optionGroup3.options[0].id },
+                ]);
+                expect(createProductVariants[1]!.options.map(pick(['id']))).toEqual([
+                    { id: optionGroup2.options[1].id },
+                    { id: optionGroup3.options[1].id },
+                ]);
+                variants = createProductVariants.filter(notNullOrUndefined);
+            });
+
+            it(
+                'createProductVariants throws if options combination already exists',
+                assertThrowsWithMessage(async () => {
+                    await client.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
+                        CREATE_PRODUCT_VARIANTS,
+                        {
+                            input: [
+                                {
+                                    productId: newProduct.id,
+                                    sku: 'PV2',
+                                    optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
+                                    translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
+                                },
+                            ],
+                        },
+                    );
+                }, 'A ProductVariant already exists with the options: 16gb, 24-inch'),
+            );
+
             it('updateProductVariants updates variants', async () => {
                 const firstVariant = variants[0];
-                const result = await client.query<
+                const { updateProductVariants } = await client.query<
                     UpdateProductVariants.Mutation,
                     UpdateProductVariants.Variables
                 >(UPDATE_PRODUCT_VARIANTS, {
@@ -600,7 +744,7 @@ describe('Product resolver', () => {
                         },
                     ],
                 });
-                const updatedVariant = result.updateProductVariants[0];
+                const updatedVariant = updateProductVariants[0];
                 if (!updatedVariant) {
                     fail('no updated variant returned.');
                     return;
@@ -697,6 +841,33 @@ describe('Product resolver', () => {
                     `No ProductVariant with the id '999' could be found`,
                 ),
             );
+
+            it('deleteProductVariant', async () => {
+                const result1 = await client.query<
+                    GetProductWithVariants.Query,
+                    GetProductWithVariants.Variables
+                >(GET_PRODUCT_WITH_VARIANTS, {
+                    id: newProduct.id,
+                });
+                expect(result1.product!.variants.map(v => v.id)).toEqual(['T_35', 'T_36', 'T_37']);
+
+                const { deleteProductVariant } = await client.query<
+                    DeleteProductVariant.Mutation,
+                    DeleteProductVariant.Variables
+                >(DELETE_PRODUCT_VARIANT, {
+                    id: result1.product!.variants[0].id,
+                });
+
+                expect(deleteProductVariant.result).toBe(DeletionResult.DELETED);
+
+                const result2 = await client.query<
+                    GetProductWithVariants.Query,
+                    GetProductWithVariants.Variables
+                >(GET_PRODUCT_WITH_VARIANTS, {
+                    id: newProduct.id,
+                });
+                expect(result2.product!.variants.map(v => v.id)).toEqual(['T_36', 'T_37']);
+            });
         });
     });
 
@@ -780,20 +951,6 @@ describe('Product resolver', () => {
                 `No Product with the id '1' could be found`,
             ),
         );
-
-        it(
-            'generateVariantsForProduct throws for deleted product',
-            assertThrowsWithMessage(
-                () =>
-                    client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                        GENERATE_PRODUCT_VARIANTS,
-                        {
-                            productId: productToDelete.id,
-                        },
-                    ),
-                `No Product with the id '1' could be found`,
-            ),
-        );
     });
 });
 
@@ -829,21 +986,24 @@ export const REMOVE_OPTION_GROUP_FROM_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 GET_OPTION_GROUP = gql`
+    query GetOptionGroup($id: ID!) {
+        productOptionGroup(id: $id) {
+            id
+            code
+            options {
+                id
+                code
+            }
+        }
+    }
+`;
+
+export const DELETE_PRODUCT_VARIANT = gql`
+    mutation DeleteProductVariant($id: ID!) {
+        deleteProductVariant(id: $id) {
+            result
+            message
         }
     }
-    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
 `;

+ 12 - 5
packages/core/mock-data/create-upload-post-data.spec.ts

@@ -1,14 +1,21 @@
 import gql from 'graphql-tag';
 
-import { CREATE_ASSETS } from '../../../admin-ui/src/app/data/definitions/product-definitions';
-
 import { createUploadPostData } from './create-upload-post-data';
 
 describe('createUploadPostData()', () => {
     it('creates correct output for createAssets mutation', () => {
-        const result = createUploadPostData(CREATE_ASSETS, ['a.jpg', 'b.jpg'], filePaths => ({
-            input: filePaths.map(() => ({ file: null })),
-        }));
+        const result = createUploadPostData(gql`
+                mutation CreateAssets($input: [CreateAssetInput!]!) {
+                    createAssets(input: $input) {
+                        id
+                        name
+                    }
+                }`
+            ,
+            ['a.jpg', 'b.jpg'],
+            filePaths => ({
+                input: filePaths.map(() => ({ file: null })),
+            }));
 
         expect(result.operations.operationName).toBe('CreateAssets');
         expect(result.operations.variables).toEqual({

+ 1 - 13
packages/core/mock-data/mock-data.service.ts

@@ -1,19 +1,7 @@
 import faker from 'faker/locale/en_GB';
 import gql from 'graphql-tag';
 
-import { CREATE_CHANNEL } from '../../../admin-ui/src/app/data/definitions/settings-definitions';
-import { CREATE_SHIPPING_METHOD } from '../../../admin-ui/src/app/data/definitions/shipping-definitions';
-import {
-    Channel,
-    CreateAddressInput,
-    CreateCustomerInput,
-    CurrencyCode,
-    LanguageCode,
-    ProductVariant,
-} from '../e2e/graphql/generated-e2e-admin-types';
-import { defaultShippingCalculator } from '../src/config/shipping-method/default-shipping-calculator';
-import { defaultShippingEligibilityChecker } from '../src/config/shipping-method/default-shipping-eligibility-checker';
-import { Customer } from '../src/entity/customer/customer.entity';
+import { CreateAddressInput, CreateCustomerInput } from '../e2e/graphql/generated-e2e-admin-types';
 
 import { SimpleGraphQLClient } from './simple-graphql-client';
 

+ 8 - 2
packages/core/mock-data/simple-graphql-client.ts

@@ -6,7 +6,6 @@ import gql from 'graphql-tag';
 import { print } from 'graphql/language/printer';
 import { Curl } from 'node-libcurl';
 
-import { CREATE_ASSETS } from '../../../admin-ui/src/app/data/definitions/product-definitions';
 import { ImportInfo } from '../e2e/graphql/generated-e2e-admin-types';
 import { getConfig } from '../src/config/config-helpers';
 
@@ -61,7 +60,14 @@ export class SimpleGraphQLClient {
 
     uploadAssets(filePaths: string[]): Promise<any> {
         return this.fileUploadMutation({
-            mutation: CREATE_ASSETS,
+            mutation: gql`
+                mutation CreateAssets($input: [CreateAssetInput!]!) {
+                    createAssets(input: $input) {
+                        id
+                        name
+                    }
+                }
+            `,
             filePaths,
             mapVariables: fp => ({
                 input: fp.map(() => ({ file: null })),

+ 6 - 0
packages/core/src/api/common/id-codec.ts

@@ -106,6 +106,9 @@ export class IdCodec {
     }
 
     private transform<T>(target: T, transformFn: (input: any) => ID, transformKeys?: string[]): T {
+        if (target == null) {
+            return target;
+        }
         const clone = Object.assign({}, target);
         if (transformKeys) {
             for (const key of transformKeys) {
@@ -123,6 +126,9 @@ export class IdCodec {
     }
 
     private isSimpleObject(target: any): boolean {
+        if (!target) {
+            return true;
+        }
         const values = Object.values(target);
         for (const value of values) {
             if (this.isObject(value) || value === null) {

+ 24 - 18
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -3,8 +3,9 @@ import {
     DeletionResponse,
     MutationAddOptionGroupToProductArgs,
     MutationCreateProductArgs,
+    MutationCreateProductVariantsArgs,
     MutationDeleteProductArgs,
-    MutationGenerateVariantsForProductArgs,
+    MutationDeleteProductVariantArgs,
     MutationRemoveOptionGroupFromProductArgs,
     MutationUpdateProductArgs,
     MutationUpdateProductVariantsArgs,
@@ -16,7 +17,6 @@ import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { UserInputError } from '../../../common/error/errors';
 import { Translated } from '../../../common/types/locale-types';
-import { assertFound } from '../../../common/utils';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { FacetValueService } from '../../../service/services/facet-value.service';
@@ -51,7 +51,11 @@ export class ProductResolver {
         @Args() args: QueryProductArgs,
     ): Promise<Translated<Product> | undefined> {
         if (args.id) {
-            return this.productService.findOne(ctx, args.id);
+            const product = await this.productService.findOne(ctx, args.id);
+            if (args.slug && product && product.slug !== args.slug) {
+                throw new UserInputError(`error.product-id-slug-mismatch`);
+            }
+            return product;
         } else if (args.slug) {
             return this.productService.findOneBySlug(ctx, args.slug);
         } else {
@@ -113,21 +117,14 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @Allow(Permission.CreateCatalog)
-    @Decode('productId', 'defaultTaxCategoryId')
-    async generateVariantsForProduct(
+    @Allow(Permission.UpdateCatalog)
+    @Decode('taxCategoryId', 'facetValueIds', 'featuredAssetId', 'assetIds', 'optionIds')
+    async createProductVariants(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationGenerateVariantsForProductArgs,
-    ): Promise<Translated<Product>> {
-        const { productId, defaultTaxCategoryId, defaultPrice, defaultSku } = args;
-        await this.productVariantService.generateVariantsForProduct(
-            ctx,
-            productId,
-            defaultTaxCategoryId,
-            defaultPrice,
-            defaultSku,
-        );
-        return assertFound(this.productService.findOne(ctx, productId));
+        @Args() args: MutationCreateProductVariantsArgs,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        const { input } = args;
+        return Promise.all(input.map(i => this.productVariantService.create(ctx, i)));
     }
 
     @Mutation()
@@ -138,6 +135,15 @@ export class ProductResolver {
         @Args() args: MutationUpdateProductVariantsArgs,
     ): Promise<Array<Translated<ProductVariant>>> {
         const { input } = args;
-        return Promise.all(input.map(variant => this.productVariantService.update(ctx, variant)));
+        return Promise.all(input.map(i => this.productVariantService.update(ctx, i)));
+    }
+
+    @Mutation()
+    @Allow(Permission.DeleteCatalog)
+    async deleteProductVariant(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationDeleteProductVariantArgs,
+    ): Promise<DeletionResponse> {
+        return this.productVariantService.softDelete(ctx, args.id);
     }
 }

+ 23 - 1
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -3,7 +3,7 @@ import { StockMovementListOptions } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Translated } from '../../../common/types/locale-types';
-import { FacetValue, ProductOption } from '../../../entity';
+import { Asset, FacetValue, ProductOption } from '../../../entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
@@ -17,6 +17,28 @@ import { Ctx } from '../../decorators/request-context.decorator';
 export class ProductVariantEntityResolver {
     constructor(private productVariantService: ProductVariantService) {}
 
+    @ResolveProperty()
+    async assets(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<Asset[]> {
+        if (productVariant.assets) {
+            return productVariant.assets;
+        }
+        return this.productVariantService.getAssetsForVariant(ctx, productVariant.id);
+    }
+
+    @ResolveProperty()
+    async featuredAsset(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<Asset> {
+        if (productVariant.featuredAsset) {
+            return productVariant.featuredAsset;
+        }
+        return this.productVariantService.getFeaturedAssetForVariant(ctx, productVariant.id);
+    }
+
     @ResolveProperty()
     async options(
         @Ctx() ctx: RequestContext,

+ 18 - 2
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -21,10 +21,13 @@ type Mutation {
     removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!): Product!
 
     "Create a set of ProductVariants based on the OptionGroups assigned to the given Product"
-    generateVariantsForProduct(productId: ID!, defaultTaxCategoryId: ID, defaultPrice: Int, defaultSku: String): Product!
+    createProductVariants(input: [CreateProductVariantInput!]!): [ProductVariant]!
 
     "Update existing ProductVariants"
     updateProductVariants(input: [UpdateProductVariantInput!]!): [ProductVariant]!
+
+    "Delete a ProductVariant"
+    deleteProductVariant(id: ID!): DeletionResponse!
 }
 
 type Product {
@@ -77,12 +80,25 @@ input ProductVariantTranslationInput {
     name: String
 }
 
+input ProductOptionTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateProductVariantOptionInput {
+    optionGroupId: ID!
+    code: String!
+    translations: [ProductOptionTranslationInput!]!
+}
+
 input CreateProductVariantInput {
+    productId: ID!
     translations: [ProductVariantTranslationInput!]!
     facetValueIds: [ID!]
     sku: String!
     price: Int
-    taxCategoryId: ID!
+    taxCategoryId: ID
     optionIds: [ID!]
     featuredAssetId: ID
     assetIds: [ID!]

+ 1 - 0
packages/core/src/api/schema/type/product-option-group.type.graphql

@@ -24,6 +24,7 @@ type ProductOption implements Node {
     languageCode: LanguageCode
     code: String
     name: String
+    groupId: ID!
     translations: [ProductOptionTranslation!]!
 }
 

+ 2 - 1
packages/core/src/data-import/providers/importer/importer.ts

@@ -208,7 +208,8 @@ export class Importer {
                 if (0 < variant.facets.length) {
                     facetValueIds = await this.getFacetValueIds(variant.facets, languageCode);
                 }
-                const createdVariant = await this.productVariantService.create(ctx, createdProduct, {
+                const createdVariant = await this.productVariantService.create(ctx, {
+                    productId: createdProduct.id as string,
                     facetValueIds,
                     featuredAssetId: variantAssets.length ? (variantAssets[0].id as string) : undefined,
                     assetIds: variantAssets.map(a => a.id) as string[],

+ 5 - 1
packages/core/src/entity/product-option-group/product-option-group.entity.ts

@@ -1,11 +1,12 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { HasCustomFields } from '@vendure/common/lib/shared-types';
-import { Column, Entity, OneToMany } from 'typeorm';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { VendureEntity } from '../base/base.entity';
 import { CustomProductOptionGroupFields } from '../custom-entity-fields';
 import { ProductOption } from '../product-option/product-option.entity';
+import { Product } from '../product/product.entity';
 
 import { ProductOptionGroupTranslation } from './product-option-group-translation.entity';
 
@@ -32,6 +33,9 @@ export class ProductOptionGroup extends VendureEntity implements Translatable, H
     @OneToMany(type => ProductOption, option => option.group)
     options: ProductOption[];
 
+    @ManyToOne(type => Product)
+    product: Product;
+
     @Column(type => CustomProductOptionGroupFields)
     customFields: CustomProductOptionGroupFields;
 }

+ 5 - 1
packages/core/src/entity/product-option/product-option.entity.ts

@@ -1,8 +1,9 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { HasCustomFields } from '@vendure/common/lib/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
+import { idType } from '../../config/config-helpers';
 import { VendureEntity } from '../base/base.entity';
 import { CustomProductOptionFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
@@ -31,6 +32,9 @@ export class ProductOption extends VendureEntity implements Translatable, HasCus
     @ManyToOne(type => ProductOptionGroup, group => group.options)
     group: ProductOptionGroup;
 
+    @Column({ type: idType() })
+    groupId: ID;
+
     @Column(type => CustomProductOptionFields)
     customFields: CustomProductOptionFields;
 }

+ 3 - 3
packages/core/src/entity/product-variant/product-variant-price.entity.ts

@@ -1,8 +1,8 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
+import { idType } from '../../config/config-helpers';
 import { VendureEntity } from '../base/base.entity';
-import { Channel } from '../channel/channel.entity';
 
 import { ProductVariant } from './product-variant.entity';
 
@@ -14,7 +14,7 @@ export class ProductVariantPrice extends VendureEntity {
 
     @Column() price: number;
 
-    @Column() channelId: number;
+    @Column({ type: idType() }) channelId: ID;
 
     @ManyToOne(type => ProductVariant, variant => variant.productVariantPrices)
     variant: ProductVariant;

+ 5 - 1
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -2,6 +2,7 @@ import { CurrencyCode } from '@vendure/common/lib/generated-types';
 import { DeepPartial, HasCustomFields } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
+import { SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
@@ -27,11 +28,14 @@ import { ProductVariantTranslation } from './product-variant-translation.entity'
  * @docsCategory entities
  */
 @Entity()
-export class ProductVariant extends VendureEntity implements Translatable, HasCustomFields {
+export class ProductVariant extends VendureEntity implements Translatable, HasCustomFields, SoftDeletable {
     constructor(input?: DeepPartial<ProductVariant>) {
         super(input);
     }
 
+    @Column({ type: Date, nullable: true, default: null })
+    deletedAt: Date | null;
+
     name: LocaleString;
 
     @Column({ default: true })

+ 2 - 2
packages/core/src/entity/product-variant/product-variant.subscriber.ts

@@ -14,7 +14,7 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface<Produ
         return ProductVariant;
     }
 
-    async afterInsert(event: InsertEvent<ProductVariant>) {
+    /*async afterInsert(event: InsertEvent<ProductVariant>) {
         const { channelId, taxCategoryId } = event.queryRunner.data;
         const price = event.entity.price || 0;
         if (channelId === undefined) {
@@ -40,5 +40,5 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface<Produ
             variantPrice.price = event.entity.price || 0;
             await event.manager.save(variantPrice);
         }
-    }
+    }*/
 }

+ 3 - 3
packages/core/src/entity/product/product.entity.ts

@@ -1,5 +1,6 @@
 import { DeepPartial, HasCustomFields } from '@vendure/common/lib/shared-types';
-import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
+import { doc } from 'prettier';
+import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
@@ -52,8 +53,7 @@ export class Product extends VendureEntity
     @OneToMany(type => ProductVariant, variant => variant.product)
     variants: ProductVariant[];
 
-    @ManyToMany(type => ProductOptionGroup)
-    @JoinTable()
+    @OneToMany(type => ProductOptionGroup, optionGroup => optionGroup.product)
     optionGroups: ProductOptionGroup[];
 
     @ManyToMany(type => FacetValue)

+ 4 - 0
packages/core/src/i18n/messages/en.json

@@ -6,6 +6,7 @@
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",
+    "cannot-remove-option-group-due-to-variants": "Cannot remove ProductOptionGroup \"{ code }\" as it is used by {count, plural, one {1 ProductVariant} other {# ProductVariants}}",
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
@@ -36,6 +37,9 @@
     "password-reset-token-has-expired": "Password reset token has expired.",
     "password-reset-token-not-recognized": "Password reset token not recognized",
     "product-id-or-slug-must-be-provided": "Either the product id or slug must be provided",
+    "product-id-slug-mismatch": "The provided id and slug refer to different Products",
+    "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
+    "product-variant-options-combination-already-exists": "A ProductVariant already exists with the options: {optionNames}",
     "refund-order-item-already-refunded": "Cannot refund an OrderItem which has already been refunded",
     "refund-order-lines-invalid-order-state": "Cannot refund an Order in the \"{ state }\" state",
     "refund-order-lines-nothing-to-refund": "Nothing to refund",

+ 56 - 0
packages/core/src/service/helpers/utils/samples-each.spec.ts

@@ -0,0 +1,56 @@
+import { samplesEach } from './samples-each';
+
+describe('samplesEach()', () => {
+
+    it('single group match', () => {
+        const result = samplesEach([1], [[1]]);
+        expect(result).toBe(true);
+    });
+
+    it('single group no match', () => {
+        const result = samplesEach([1], [[3, 4, 5]]);
+        expect(result).toBe(false);
+    });
+
+    it('does not sample all groups', () => {
+        const result = samplesEach([1, 3], [[0, 1, 3], [2, 5, 4]]);
+        expect(result).toBe(false);
+    });
+
+    it('two groups in order', () => {
+        const result = samplesEach([1, 4], [[0, 1, 3], [2, 5, 4]]);
+        expect(result).toBe(true);
+    });
+
+    it('two groups not in order', () => {
+        const result = samplesEach([1, 4], [[2, 5, 4], [0, 1, 3]]);
+        expect(result).toBe(true);
+    });
+
+    it('three groups in order', () => {
+        const result = samplesEach([1, 4, 'a'], [[0, 1, 3], [2, 5, 4], ['b', 'a']]);
+        expect(result).toBe(true);
+    });
+
+    it('three groups not in order', () => {
+        const result = samplesEach([1, 4, 'a'], [[2, 5, 4], ['b', 'a'], [0, 1, 3]]);
+        expect(result).toBe(true);
+    });
+
+    it('input is unchanged', () => {
+        const input = [1, 4, 'a'];
+        const result = samplesEach(input, [[2, 5, 4], ['b', 'a'], [0, 1, 3]]);
+        expect(result).toBe(true);
+        expect(input).toEqual([1, 4, 'a']);
+    });
+
+    it('empty input arrays', () => {
+        const result = samplesEach([], []);
+        expect(result).toBe(true);
+    });
+
+    it('length mismatch', () => {
+        const result = samplesEach([1, 4, 5], [[2, 5, 4], [0, 1, 3]]);
+        expect(result).toBe(false);
+    });
+});

+ 17 - 0
packages/core/src/service/helpers/utils/samples-each.ts

@@ -0,0 +1,17 @@
+/**
+ * Returns true if and only if exactly one item from each
+ * of the "groups" arrays appears in the "sample" array.
+ */
+export function samplesEach<T>(sample: T[], groups: T[][]): boolean {
+    if (sample.length !== groups.length) {
+        return false;
+    }
+    return groups.every(group => {
+        for (const item of sample) {
+            if (group.includes(item)) {
+                return true;
+            }
+        }
+        return false;
+    });
+}

+ 136 - 23
packages/core/src/service/services/product-variant.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { CreateProductVariantInput, UpdateProductVariantInput } from '@vendure/common/lib/generated-types';
+import { CreateProductVariantInput, DeletionResponse, DeletionResult, UpdateProductVariantInput } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { generateAllCombinations } from '@vendure/common/lib/shared-utils';
 import { Connection } from 'typeorm';
@@ -12,7 +12,7 @@ import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { TaxCategory } from '../../entity';
+import { Asset, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { ProductOption } from '../../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity';
@@ -25,6 +25,7 @@ import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-build
 import { TaxCalculator } from '../helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
+import { samplesEach } from '../helpers/utils/samples-each';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { FacetValueService } from './facet-value.service';
@@ -70,6 +71,7 @@ export class ProductVariantService {
             .find({
                 where: {
                     product: { id: productId } as any,
+                    deletedAt: null,
                 },
                 relations: [
                     'options',
@@ -111,17 +113,16 @@ export class ProductVariantService {
             qb.andWhere('product.enabled = :enabled', { enabled: true });
         }
 
-        return qb.getManyAndCount()
-            .then(async ([variants, totalItems]) => {
-                const items = variants.map(variant => {
-                    const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
-                    return translateDeep(variantWithPrices, ctx.languageCode);
-                });
-                return {
-                    items,
-                    totalItems,
-                };
+        return qb.getManyAndCount().then(async ([variants, totalItems]) => {
+            const items = variants.map(variant => {
+                const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
+                return translateDeep(variantWithPrices, ctx.languageCode);
             });
+            return {
+                items,
+                totalItems,
+            };
+        });
     }
 
     getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<ProductOption>>> {
@@ -131,6 +132,20 @@ export class ProductVariantService {
             .then(variant => (!variant ? [] : variant.options.map(o => translateDeep(o, ctx.languageCode))));
     }
 
+    async getAssetsForVariant(ctx: RequestContext, variantId: ID): Promise<Asset[]> {
+        const variant = await getEntityOrThrow(this.connection, ProductVariant, variantId, {
+            relations: ['assets'],
+        });
+        return variant.assets;
+    }
+
+    async getFeaturedAssetForVariant(ctx: RequestContext, variantId: ID): Promise<Asset> {
+        const variant = await getEntityOrThrow(this.connection, ProductVariant, variantId, {
+            relations: ['featuredAsset'],
+        });
+        return variant.featuredAsset;
+    }
+
     getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<FacetValue>>> {
         return this.connection
             .getRepository(ProductVariant)
@@ -140,11 +155,16 @@ export class ProductVariantService {
             );
     }
 
-    async create(
-        ctx: RequestContext,
-        product: Product,
-        input: CreateProductVariantInput,
-    ): Promise<ProductVariant> {
+    async create(ctx: RequestContext, input: CreateProductVariantInput): Promise<Translated<ProductVariant>> {
+        await this.validateVariantOptionIds(input);
+        if (!input.optionIds) {
+            input.optionIds = [];
+        }
+        if (input.price == null) {
+            input.price = 0;
+        }
+        input.taxCategoryId = (await this.getTaxCategoryForNewVariant(input.taxCategoryId)).id as string;
+
         const createdVariant = await this.translatableSaver.create({
             input,
             entityType: ProductVariant,
@@ -163,7 +183,7 @@ export class ProductVariantService {
                 if (input.trackInventory == null) {
                     variant.trackInventory = (await this.globalSettingsService.getSettings()).trackInventory;
                 }
-                variant.product = product;
+                variant.product = { id: input.productId } as any;
                 variant.taxCategory = { id: input.taxCategoryId } as any;
                 await this.assetUpdater.updateEntityAssets(variant, input);
             },
@@ -173,9 +193,20 @@ export class ProductVariantService {
             },
         });
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
-            await this.stockMovementService.adjustProductVariantStock(createdVariant.id, 0, input.stockOnHand);
+            await this.stockMovementService.adjustProductVariantStock(
+                createdVariant.id,
+                0,
+                input.stockOnHand,
+            );
         }
-        return createdVariant;
+        const variantPrice = new ProductVariantPrice({
+            price: createdVariant.price,
+            channelId: ctx.channelId,
+        });
+        variantPrice.variant = createdVariant;
+        await this.connection.getRepository(ProductVariantPrice).save(variantPrice);
+        this.eventBus.publish(new CatalogModificationEvent(ctx, createdVariant));
+        return await assertFound(this.findOne(ctx, createdVariant.id));
     }
 
     async update(ctx: RequestContext, input: UpdateProductVariantInput): Promise<Translated<ProductVariant>> {
@@ -198,7 +229,11 @@ export class ProductVariantService {
                     updatedVariant.facetValues = await this.facetValueService.findByIds(input.facetValueIds);
                 }
                 if (input.stockOnHand != null) {
-                    await this.stockMovementService.adjustProductVariantStock(existingVariant.id, existingVariant.stockOnHand, input.stockOnHand);
+                    await this.stockMovementService.adjustProductVariantStock(
+                        existingVariant.id,
+                        existingVariant.stockOnHand,
+                        input.stockOnHand,
+                    );
                 }
                 await this.assetUpdater.updateEntityAssets(updatedVariant, input);
             },
@@ -207,6 +242,20 @@ export class ProductVariantService {
                 taxCategoryId: input.taxCategoryId,
             },
         });
+        if (input.price != null) {
+            const variantPriceRepository = this.connection.getRepository(ProductVariantPrice);
+            const variantPrice = await variantPriceRepository.findOne({
+                where: {
+                    variant: input.id,
+                    channelId: ctx.channelId,
+                },
+            });
+            if (!variantPrice) {
+                throw new InternalServerError(`error.could-not-find-product-variant-price`);
+            }
+            variantPrice.price = input.price;
+            await variantPriceRepository.save(variantPrice);
+        }
         const variant = await assertFound(
             this.connection.manager.getRepository(ProductVariant).findOne(input.id, {
                 relations: [
@@ -220,13 +269,23 @@ export class ProductVariantService {
             }),
         );
         this.eventBus.publish(new CatalogModificationEvent(ctx, variant));
-        return translateDeep(this.applyChannelPriceAndTax(variant, ctx), DEFAULT_LANGUAGE_CODE, [
+        return translateDeep(this.applyChannelPriceAndTax(variant, ctx), ctx.languageCode, [
             'options',
             'facetValues',
             ['facetValues', 'facet'],
         ]);
     }
 
+    async softDelete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
+        const variant = await getEntityOrThrow(this.connection, ProductVariant, id);
+        variant.deletedAt = new Date();
+        await this.connection.getRepository(ProductVariant).save(variant);
+        this.eventBus.publish(new CatalogModificationEvent(ctx, variant));
+        return {
+            result: DeletionResult.DELETED,
+        };
+    }
+
     async generateVariantsForProduct(
         ctx: RequestContext,
         productId: ID,
@@ -265,7 +324,8 @@ export class ProductVariantService {
         const variants: ProductVariant[] = [];
         for (const options of optionCombinations) {
             const name = this.createVariantName(productName, options);
-            const variant = await this.create(ctx, product, {
+            const variant = await this.create(ctx, {
+                productId: productId as string,
                 sku: defaultSku || 'sku-not-set',
                 price: defaultPrice || 0,
                 optionIds: options.map(o => o.id) as string[],
@@ -314,6 +374,59 @@ export class ProductVariantService {
         return variant;
     }
 
+    private async validateVariantOptionIds(input: CreateProductVariantInput) {
+        const product = await getEntityOrThrow(this.connection, Product, input.productId, {
+            relations: ['optionGroups', 'optionGroups.options', 'variants', 'variants.options'],
+        });
+        const optionIds = [...(input.optionIds || [])];
+
+        if (optionIds.length !== product.optionGroups.length) {
+            this.throwIncompatibleOptionsError(product.optionGroups);
+        }
+        if (!samplesEach(optionIds, product.optionGroups.map(g => g.options.map(o => o.id)))) {
+            this.throwIncompatibleOptionsError(product.optionGroups);
+        }
+        product.variants.forEach(variant => {
+            const variantOptionIds = this.sortJoin(variant.options, ',', 'id');
+            const inputOptionIds = this.sortJoin(input.optionIds || [], ',');
+            if (variantOptionIds === inputOptionIds) {
+                throw new UserInputError('error.product-variant-options-combination-already-exists', {
+                    optionNames: this.sortJoin(variant.options, ', ', 'code'),
+                });
+            }
+        });
+    }
+
+    private throwIncompatibleOptionsError(optionGroups: ProductOptionGroup[]) {
+        throw new UserInputError('error.product-variant-option-ids-not-compatible', {
+            groupNames: this.sortJoin(optionGroups, ', ', 'code'),
+        });
+    }
+
+    private sortJoin<T>(arr: T[], glue: string, prop?: keyof T): string {
+        return arr
+            .map(x => prop ? x[prop] : x)
+            .sort()
+            .join(glue);
+    }
+
+    private async getTaxCategoryForNewVariant(
+        taxCategoryId: string | null | undefined,
+    ): Promise<TaxCategory> {
+        let taxCategory: TaxCategory;
+        if (taxCategoryId) {
+            taxCategory = await getEntityOrThrow(this.connection, TaxCategory, taxCategoryId);
+        } else {
+            const taxCategories = await this.taxCategoryService.findAll();
+            taxCategory = taxCategories[0];
+        }
+        if (!taxCategory) {
+            // there is no TaxCategory set up, so create a default
+            taxCategory = await this.taxCategoryService.create({ name: 'Standard Tax' });
+        }
+        return taxCategory;
+    }
+
     private createVariantName(productName: string, options: ProductOption[]): string {
         const optionsSuffix = options
             .map(option => {

+ 15 - 4
packages/core/src/service/services/product.service.ts

@@ -11,10 +11,10 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError } from '../../common/error/errors';
+import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
-import { assertFound } from '../../common/utils';
+import { assertFound, idsAreEqual } from '../../common/utils';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
@@ -150,7 +150,8 @@ export class ProductService {
 
     async softDelete(ctx: RequestContext, productId: ID): Promise<DeletionResponse> {
         const product = await getEntityOrThrow(this.connection, Product, productId);
-        await this.connection.getRepository(Product).update({ id: productId }, { deletedAt: new Date() });
+        product.deletedAt = new Date();
+        await this.connection.getRepository(Product).save(product);
         this.eventBus.publish(new CatalogModificationEvent(ctx, product));
         return {
             result: DeletionResult.DELETED,
@@ -184,6 +185,16 @@ export class ProductService {
         optionGroupId: ID,
     ): Promise<Translated<Product>> {
         const product = await this.getProductWithOptionGroups(productId);
+        const optionGroup = product.optionGroups.find(g => idsAreEqual(g.id, optionGroupId));
+        if (!optionGroup) {
+            throw new EntityNotFoundError('ProductOptionGroup', optionGroupId);
+        }
+        if (product.variants.length) {
+            throw new UserInputError('error.cannot-remove-option-group-due-to-variants', {
+                code: optionGroup.code,
+                count: product.variants.length,
+            });
+        }
         product.optionGroups = product.optionGroups.filter(g => g.id !== optionGroupId);
 
         await this.connection.manager.save(product);
@@ -193,7 +204,7 @@ export class ProductService {
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {
         const product = await this.connection
             .getRepository(Product)
-            .findOne(productId, { relations: ['optionGroups'], where: { deletedAt: null } });
+            .findOne(productId, { relations: ['optionGroups', 'variants', 'variants.options'], where: { deletedAt: null } });
         if (!product) {
             throw new EntityNotFoundError('Product', productId);
         }

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


Some files were not shown because too many files changed in this diff