Просмотр исходного кода

feat(admin-ui): Implement adding new variants by extending options

Relates to #162
Michael Bromley 6 лет назад
Родитель
Сommit
fefe0ea95f
21 измененных файлов с 798 добавлено и 84 удалено
  1. 10 1
      packages/admin-ui/src/app/catalog/catalog.module.ts
  2. 32 0
      packages/admin-ui/src/app/catalog/catalog.routes.ts
  3. 5 3
      packages/admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.ts
  4. 3 2
      packages/admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.html
  5. 3 0
      packages/admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.scss
  6. 13 8
      packages/admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.ts
  7. 6 1
      packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  8. 4 0
      packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.scss
  9. 0 28
      packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  10. 129 0
      packages/admin-ui/src/app/catalog/components/product-variants-editor/product-variants-editor.component.html
  11. 22 0
      packages/admin-ui/src/app/catalog/components/product-variants-editor/product-variants-editor.component.scss
  12. 371 0
      packages/admin-ui/src/app/catalog/components/product-variants-editor/product-variants-editor.component.ts
  13. 0 16
      packages/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.html
  14. 0 5
      packages/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts
  15. 42 20
      packages/admin-ui/src/app/catalog/providers/product-detail.service.ts
  16. 21 0
      packages/admin-ui/src/app/catalog/providers/routing/product-variants-resolver.ts
  17. 43 0
      packages/admin-ui/src/app/common/generated-types.ts
  18. 54 0
      packages/admin-ui/src/app/data/definitions/product-definitions.ts
  19. 32 0
      packages/admin-ui/src/app/data/providers/product-data.service.ts
  20. 4 0
      packages/admin-ui/src/app/shared/components/currency-input/currency-input.component.scss
  21. 4 0
      packages/admin-ui/src/i18n-messages/en.json

+ 10 - 1
packages/admin-ui/src/app/catalog/catalog.module.ts

@@ -24,6 +24,7 @@ import { ProductAssetsComponent } from './components/product-assets/product-asse
 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 { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
 import { ProductVariantsListComponent } from './components/product-variants-list/product-variants-list.component';
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
 import { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.component';
@@ -32,6 +33,7 @@ import { ProductDetailService } from './providers/product-detail.service';
 import { CollectionResolver } from './providers/routing/collection-resolver';
 import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
+import { ProductVariantsResolver } from './providers/routing/product-variants-resolver';
 
 @NgModule({
     imports: [SharedModule, RouterModule.forChild(catalogRoutes), DragDropModule],
@@ -60,6 +62,7 @@ import { ProductResolver } from './providers/routing/product-resolver';
         ProductSearchInputComponent,
         OptionValueInputComponent,
         UpdateProductOptionDialogComponent,
+        ProductVariantsEditorComponent,
     ],
     entryComponents: [
         AssetPickerDialogComponent,
@@ -67,6 +70,12 @@ import { ProductResolver } from './providers/routing/product-resolver';
         AssetPreviewComponent,
         UpdateProductOptionDialogComponent,
     ],
-    providers: [ProductResolver, FacetResolver, CollectionResolver, ProductDetailService],
+    providers: [
+        ProductResolver,
+        FacetResolver,
+        CollectionResolver,
+        ProductDetailService,
+        ProductVariantsResolver,
+    ],
 })
 export class CatalogModule {}

+ 32 - 0
packages/admin-ui/src/app/catalog/catalog.routes.ts

@@ -16,9 +16,11 @@ import { FacetDetailComponent } from './components/facet-detail/facet-detail.com
 import { FacetListComponent } from './components/facet-list/facet-list.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
+import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
 import { CollectionResolver } from './providers/routing/collection-resolver';
 import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
+import { ProductVariantsResolver } from './providers/routing/product-variants-resolver';
 
 export const catalogRoutes: Route[] = [
     {
@@ -37,6 +39,15 @@ export const catalogRoutes: Route[] = [
             breadcrumb: productBreadcrumb,
         },
     },
+    {
+        path: 'products/:id/manage-variants',
+        component: ProductVariantsEditorComponent,
+        resolve: createResolveData(ProductVariantsResolver),
+        canDeactivate: [CanDeactivateDetailGuard],
+        data: {
+            breadcrumb: productVariantEditorBreadcrumb,
+        },
+    },
     {
         path: 'facets',
         component: FacetListComponent,
@@ -88,6 +99,27 @@ export function productBreadcrumb(data: any, params: any) {
     });
 }
 
+export function productVariantEditorBreadcrumb(data: any, params: any) {
+    return data.entity.pipe(
+        map((entity: any) => {
+            return [
+                {
+                    label: _('breadcrumb.products'),
+                    link: ['../', 'products'],
+                },
+                {
+                    label: `#${params.id} (${entity.name})`,
+                    link: ['../', 'products', params.id, { tab: 'variants' }],
+                },
+                {
+                    label: _('breadcrumb.manage-variants'),
+                    link: ['manage-variants'],
+                },
+            ];
+        }),
+    );
+}
+
 export function facetBreadcrumb(data: any, params: any) {
     return detailBreadcrumb<FacetWithValues.Fragment>({
         entity: data.entity,

+ 5 - 3
packages/admin-ui/src/app/catalog/components/generate-product-variants/generate-product-variants.component.ts

@@ -25,7 +25,7 @@ export type CreateProductVariantsConfig = {
 })
 export class GenerateProductVariantsComponent implements OnInit {
     @Output() variantsChange = new EventEmitter<CreateProductVariantsConfig>();
-    optionGroups: Array<{ name: string; values: string[] }> = [];
+    optionGroups: Array<{ name: string; values: Array<{ name: string; locked: boolean }> }> = [];
     currencyCode: CurrencyCode;
     variants: Array<{ id: string; values: string[] }>;
     variantFormValues: { [id: string]: CreateVariantValues } = {};
@@ -50,7 +50,9 @@ export class GenerateProductVariantsComponent implements OnInit {
 
     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]];
+        const groups = totalValuesCount
+            ? this.optionGroups.map(g => g.values.map(v => v.name))
+            : [[DEFAULT_VARIANT_CODE]];
         this.variants = generateAllCombinations(groups).map(values => ({ id: values.join('|'), values }));
 
         this.variants.forEach(variant => {
@@ -80,7 +82,7 @@ export class GenerateProductVariantsComponent implements OnInit {
     onFormChange() {
         const variantsToCreate = this.variants.map(v => this.variantFormValues[v.id]).filter(v => v.enabled);
         this.variantsChange.emit({
-            groups: this.optionGroups,
+            groups: this.optionGroups.map(og => ({ name: og.name, values: og.values.map(v => v.name) })),
             variants: variantsToCreate,
         });
     }

+ 3 - 2
packages/admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.html

@@ -2,12 +2,13 @@
     <div class="chips" *ngIf="0 < options.length">
         <vdr-chip
             *ngFor="let option of options; last as isLast"
-            icon="times"
+            [icon]="option.locked ? 'lock' : 'times'"
             [class.selected]="isLast && lastSelected"
+            [class.locked]="option.locked"
             [colorFrom]="groupName"
             (iconClick)="removeOption(option)"
         >
-            {{ option }}
+            {{ option.name }}
         </vdr-chip>
     </div>
     <textarea

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

@@ -34,6 +34,9 @@ vdr-chip {
     ::ng-deep .wrapper {
         margin: 0 3px;
     }
+    &.locked {
+        opacity: 0.8;
+    }
     &.selected {
         ::ng-deep .wrapper {
             border-color: $color-warning-500 !important;

+ 13 - 8
packages/admin-ui/src/app/catalog/components/option-value-input/option-value-input.component.ts

@@ -21,13 +21,13 @@ export const OPTION_VALUE_INPUT_VALUE_ACCESSOR: Provider = {
     selector: 'vdr-option-value-input',
     templateUrl: './option-value-input.component.html',
     styleUrls: ['./option-value-input.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
+    changeDetection: ChangeDetectionStrategy.Default,
     providers: [OPTION_VALUE_INPUT_VALUE_ACCESSOR],
 })
 export class OptionValueInputComponent implements ControlValueAccessor {
     @Input() groupName = '';
     @ViewChild('textArea', { static: true }) textArea: ElementRef<HTMLTextAreaElement>;
-    options: string[];
+    options: Array<{ name: string; locked: boolean }>;
     disabled = false;
     input = '';
     isFocussed = false;
@@ -58,9 +58,11 @@ export class OptionValueInputComponent implements ControlValueAccessor {
         this.textArea.nativeElement.focus();
     }
 
-    removeOption(option: string) {
-        this.options = this.options.filter(o => o !== option);
-        this.onChangeFn(this.options);
+    removeOption(option: { name: string; locked: boolean }) {
+        if (!option.locked) {
+            this.options = this.options.filter(o => o.name !== option.name);
+            this.onChangeFn(this.options);
+        }
     }
 
     handleKey(event: KeyboardEvent) {
@@ -94,14 +96,17 @@ export class OptionValueInputComponent implements ControlValueAccessor {
         this.onChangeFn(this.options);
     }
 
-    private parseInputIntoOptions(input: string): string[] {
+    private parseInputIntoOptions(input: string): Array<{ name: string; locked: boolean }> {
         return input
             .split(/[,\n]/)
             .map(s => s.trim())
-            .filter(s => s !== '');
+            .filter(s => s !== '')
+            .map(s => ({ name: s, locked: false }));
     }
 
     private removeLastOption() {
-        this.options = this.options.slice(0, this.options.length - 1);
+        if (!this.options[this.options.length - 1].locked) {
+            this.options = this.options.slice(0, this.options.length - 1);
+        }
     }
 }

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

@@ -136,6 +136,12 @@
                                 {{ 'catalog.display-variant-table' | translate }}
                             </button>
                         </div>
+                        <div class="flex-spacer"></div>
+                        <a [routerLink]="['./', 'manage-variants']"
+                           class="btn btn-secondary btn-sm edit-variants-btn">
+                            <clr-icon shape="add-text"></clr-icon>
+                            {{ 'catalog.manage-variants' | translate }}
+                        </a>
                     </div>
 
                     <vdr-product-variants-table
@@ -156,7 +162,6 @@
                         (updateProductOption)="updateProductOption($event)"
                         (selectionChange)="selectedVariantIds = $event"
                         (selectFacetValueClick)="selectVariantFacetValue($event)"
-                        (deleteVariant)="deleteVariant($event)"
                     ></vdr-product-variants-list>
                 </section>
             </clr-tab-content>

+ 4 - 0
packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.scss

@@ -20,3 +20,7 @@ vdr-action-bar clr-toggle-wrapper {
     display: flex;
     justify-content: flex-end;
 }
+
+.edit-variants-btn {
+    margin-top: 0;
+}

+ 0 - 28
packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -254,34 +254,6 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         );
     }
 
-    deleteVariant(id: string) {
-        this.modalService
-            .dialog({
-                title: _('catalog.confirm-delete-product-variant'),
-                buttons: [
-                    { type: 'seconday', label: _('common.cancel') },
-                    { type: 'danger', label: _('common.delete'), returnValue: true },
-                ],
-            })
-            .pipe(
-                switchMap(response =>
-                    response ? this.productDetailService.deleteProductVariant(id, this.id) : EMPTY,
-                ),
-            )
-            .subscribe(
-                () => {
-                    this.notificationService.success(_('common.notify-delete-success'), {
-                        entity: 'ProductVariant',
-                    });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-delete-error'), {
-                        entity: 'ProductVariant',
-                    });
-                },
-            );
-    }
-
     private displayFacetValueModal(): Observable<string[] | undefined> {
         return this.productDetailService.getFacets().pipe(
             mergeMap(facets =>

+ 129 - 0
packages/admin-ui/src/app/catalog/components/product-variants-editor/product-variants-editor.component.html

@@ -0,0 +1,129 @@
+<vdr-action-bar>
+    <vdr-ab-right>
+        <button
+            class="btn btn-primary"
+            (click)="save()"
+            [disabled]="!formValueChanged || getVariantsToAdd().length === 0"
+        >
+            {{ 'common.add-new-variants' | translate: { count: getVariantsToAdd().length } }}
+        </button>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<div *ngFor="let group of optionGroups" class="option-groups">
+    <div class="name">
+        <label>{{ 'catalog.option' | translate }}</label>
+        <input
+            clrInput
+            [(ngModel)]="group.name"
+            name="name"
+            readonly
+        />
+    </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>
+<button class="btn btn-primary-outline btn-sm" (click)="addOption()">
+    <clr-icon shape="plus"></clr-icon>
+    {{ 'catalog.add-option' | translate }}
+</button>
+
+<div class="variants-preview">
+    <table class="table">
+        <thead>
+        <tr>
+            <th>{{ 'common.create' | translate }}</th>
+            <th>{{ 'catalog.variant' | translate }}</th>
+            <th>{{ 'catalog.sku' | translate }}</th>
+            <th>{{ 'catalog.price' | translate }}</th>
+            <th>{{ 'catalog.stock-on-hand' | translate }}</th>
+            <th></th>
+        </tr>
+        </thead>
+        <tr
+            *ngFor="let variant of variants; trackBy: trackByFn"
+            [class.disabled]="!variantFormValues[variant.id].enabled || variantFormValues[variant.id].existing"
+        >
+            <td>
+                <input
+                    type="checkbox"
+                    *ngIf="!variantFormValues[variant.id].existing"
+                    [(ngModel)]="variantFormValues[variant.id].enabled"
+                    name="enabled"
+                    clrCheckbox
+                    (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                />
+            </td>
+            <td>
+                {{ getVariantName(variant) }}
+            </td>
+            <td>
+                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                    <input
+                        clrInput
+                        type="text"
+                        [(ngModel)]="variantFormValues[variant.id].sku"
+                        [placeholder]="'catalog.sku' | translate"
+                        name="sku"
+                        required
+                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                    />
+                </clr-input-container>
+                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].sku }}</span>
+            </td>
+            <td>
+                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                    <vdr-currency-input
+                        clrInput
+                        [(ngModel)]="variantFormValues[variant.id].price"
+                        name="price"
+                        [currencyCode]="currencyCode"
+                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                    ></vdr-currency-input>
+                </clr-input-container>
+                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].price / 100 | currency: currencyCode }}</span>
+            </td>
+            <td>
+                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                    <input
+                        clrInput
+                        type="number"
+                        [(ngModel)]="variantFormValues[variant.id].stock"
+                        name="stock"
+                        min="0"
+                        step="1"
+                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                    />
+                </clr-input-container>
+                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].stock }}</span>
+            </td>
+            <td>
+                <vdr-dropdown *ngIf="variantFormValues[variant.id].productVariantId as productVariantId">
+                    <button class="icon-button" vdrDropdownTrigger>
+                        <clr-icon shape="ellipsis-vertical"></clr-icon>
+                    </button>
+                    <vdr-dropdown-menu vdrPosition="bottom-right">
+                        <button
+                            type="button"
+                            class="delete-button"
+                            (click)="deleteVariant(productVariantId)"
+                            vdrDropdownItem
+                        >
+                            <clr-icon shape="trash" class="is-danger"></clr-icon>
+                            {{ 'common.delete' | translate }}
+                        </button>
+                    </vdr-dropdown-menu>
+
+                </vdr-dropdown>
+            </td>
+        </tr>
+    </table>
+</div>

+ 22 - 0
packages/admin-ui/src/app/catalog/components/product-variants-editor/product-variants-editor.component.scss

@@ -0,0 +1,22 @@
+@import "variables";
+
+.option-groups {
+    display: flex;
+    &:first-of-type {
+        margin-top: 24px;
+    }
+}
+
+.values {
+    flex: 1;
+    margin: 0 6px;
+}
+
+.variants-preview {
+    tr.disabled {
+        td {
+            background-color: $color-grey-100;
+            color: $color-grey-400;
+        }
+    }
+}

+ 371 - 0
packages/admin-ui/src/app/catalog/components/product-variants-editor/product-variants-editor.component.ts

@@ -0,0 +1,371 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { ProductDetailService } from '@vendure/admin-ui/src/app/catalog/providers/product-detail.service';
+import { getDefaultLanguage } from '@vendure/admin-ui/src/app/common/utilities/get-default-language';
+import { NotificationService } from '@vendure/admin-ui/src/app/core/providers/notification/notification.service';
+import { DataService } from '@vendure/admin-ui/src/app/data/providers/data.service';
+import { ModalService } from '@vendure/admin-ui/src/app/shared/providers/modal/modal.service';
+import { EMPTY, forkJoin, Observable, of } from 'rxjs';
+import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
+import { normalizeString } from 'shared/normalize-string';
+import { generateAllCombinations, notNullOrUndefined } from 'shared/shared-utils';
+
+import { DeactivateAware } from '../../../common/deactivate-aware';
+import {
+    CreateProductOptionGroup,
+    CreateProductOptionInput,
+    CurrencyCode,
+    GetProductVariantOptions,
+    LanguageCode,
+    ProductOptionGroupFragment,
+} from '../../../common/generated-types';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+
+export interface VariantInfo {
+    productVariantId?: string;
+    enabled: boolean;
+    existing: boolean;
+    options: string[];
+    sku: string;
+    price: number;
+    stock: number;
+}
+
+export interface GeneratedVariant {
+    isDefault: boolean;
+    id: string;
+    options: Array<{ name: string; id?: string }>;
+}
+
+const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
+
+@Component({
+    selector: 'vdr-product-variants-editor',
+    templateUrl: './product-variants-editor.component.html',
+    styleUrls: ['./product-variants-editor.component.scss'],
+    changeDetection: ChangeDetectionStrategy.Default,
+})
+export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
+    formValueChanged = false;
+    variants: GeneratedVariant[] = [];
+    optionGroups: Array<{
+        id?: string;
+        isNew: boolean;
+        name: string;
+        values: Array<{
+            id?: string;
+            name: string;
+            locked: boolean;
+        }>;
+    }>;
+    variantFormValues: { [id: string]: VariantInfo } = {};
+    private currencyCode: CurrencyCode;
+    private languageCode: LanguageCode;
+    private product: GetProductVariantOptions.Product;
+
+    constructor(
+        private route: ActivatedRoute,
+        private dataService: DataService,
+        private productDetailService: ProductDetailService,
+        private notificationService: NotificationService,
+        private modalService: ModalService,
+    ) {}
+
+    ngOnInit() {
+        this.initOptionsAndVariants();
+        this.languageCode =
+            (this.route.snapshot.paramMap.get('lang') as LanguageCode) || getDefaultLanguage();
+        this.dataService.settings.getActiveChannel().single$.subscribe(data => {
+            this.currencyCode = data.activeChannel.currencyCode;
+        });
+    }
+
+    onFormChanged(variantInfo: VariantInfo) {
+        this.formValueChanged = true;
+        variantInfo.enabled = true;
+    }
+
+    canDeactivate(): boolean {
+        return !this.formValueChanged;
+    }
+
+    getVariantsToAdd() {
+        return Object.values(this.variantFormValues).filter(v => !v.existing && v.enabled);
+    }
+
+    getVariantName(variant: GeneratedVariant) {
+        return variant.options.length === 0
+            ? _('catalog.default-variant')
+            : variant.options.map(o => o.name).join(' ');
+    }
+
+    addOption() {
+        this.optionGroups.push({
+            isNew: true,
+            name: '',
+            values: [],
+        });
+    }
+
+    generateVariants() {
+        const groups = this.optionGroups.map(g => g.values);
+        const previousVariants = this.variants;
+        this.variants = groups.length
+            ? generateAllCombinations(groups).map((options, i) => ({
+                  isDefault: this.product.variants.length === 1 && i === 0,
+                  id: options.map(o => o.name).join('|'),
+                  options,
+              }))
+            : [{ isDefault: true, id: DEFAULT_VARIANT_CODE, options: [] }];
+
+        this.variants.forEach(variant => {
+            if (!this.variantFormValues[variant.id]) {
+                const prototype = this.getVariantPrototype(variant, previousVariants);
+                this.variantFormValues[variant.id] = {
+                    enabled: false,
+                    existing: false,
+                    options: variant.options.map(o => o.name),
+                    price: prototype.price,
+                    sku: prototype.sku,
+                    stock: prototype.stock,
+                };
+            }
+        });
+    }
+
+    /**
+     * Returns one of the existing variants to base the newly-generated variant's
+     * details off.
+     */
+    private getVariantPrototype(
+        variant: GeneratedVariant,
+        previousVariants: GeneratedVariant[],
+    ): Pick<VariantInfo, 'sku' | 'price' | 'stock'> {
+        if (variant.isDefault) {
+            return this.variantFormValues[DEFAULT_VARIANT_CODE];
+        }
+        const variantsWithSimilarOptions = previousVariants.filter(v =>
+            variant.options.map(o => o.name).filter(name => v.options.map(o => o.name).includes(name)),
+        );
+        if (variantsWithSimilarOptions.length) {
+            return this.variantFormValues[variantsWithSimilarOptions[0].options.map(o => o.name).join('|')];
+        }
+        return {
+            sku: '',
+            price: 0,
+            stock: 0,
+        };
+    }
+
+    deleteVariant(id: string) {
+        this.modalService
+            .dialog({
+                title: _('catalog.confirm-delete-product-variant'),
+                buttons: [
+                    { type: 'seconday', label: _('common.cancel') },
+                    { type: 'danger', label: _('common.delete'), returnValue: true },
+                ],
+            })
+            .pipe(
+                switchMap(response =>
+                    response ? this.productDetailService.deleteProductVariant(id, this.product.id) : EMPTY,
+                ),
+                switchMap(() => this.reFetchProduct(null)),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('common.notify-delete-success'), {
+                        entity: 'ProductVariant',
+                    });
+                    this.initOptionsAndVariants();
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-delete-error'), {
+                        entity: 'ProductVariant',
+                    });
+                },
+            );
+    }
+
+    save() {
+        const newOptionGroups = this.optionGroups
+            .filter(og => og.isNew)
+            .map(og => ({
+                name: og.name,
+                values: [],
+            }));
+
+        this.productDetailService
+            .createProductOptionGroups(newOptionGroups, this.languageCode)
+            .pipe(
+                mergeMap(createdOptionGroups => this.addOptionGroupsToProduct(createdOptionGroups)),
+                mergeMap(createdOptionGroups => this.addNewOptionsToGroups(createdOptionGroups)),
+                mergeMap(groupsIds => this.fetchOptionGroups(groupsIds)),
+                mergeMap(groups => this.createNewProductVariants(groups)),
+                mergeMap(res => this.deleteDefaultVariant(res.createProductVariants)),
+                mergeMap(variants => this.reFetchProduct(variants)),
+            )
+            .subscribe({
+                next: variants => {
+                    this.formValueChanged = false;
+                    this.notificationService.success(_('catalog.created-new-variants-success'), {
+                        count: variants.length,
+                    });
+                    this.initOptionsAndVariants();
+                },
+            });
+    }
+
+    private addOptionGroupsToProduct(
+        createdOptionGroups: CreateProductOptionGroup.CreateProductOptionGroup[],
+    ): Observable<CreateProductOptionGroup.CreateProductOptionGroup[]> {
+        if (createdOptionGroups.length) {
+            return forkJoin(
+                createdOptionGroups.map(optionGroup => {
+                    return this.dataService.product.addOptionGroupToProduct({
+                        productId: this.product.id,
+                        optionGroupId: optionGroup.id,
+                    });
+                }),
+            ).pipe(map(() => createdOptionGroups));
+        } else {
+            return of([]);
+        }
+    }
+
+    private addNewOptionsToGroups(
+        createdOptionGroups: CreateProductOptionGroup.CreateProductOptionGroup[],
+    ): Observable<string[]> {
+        const newOptions: CreateProductOptionInput[] = this.optionGroups
+            .map(og => {
+                const createdGroup = createdOptionGroups.find(cog => cog.name === og.name);
+                const productOptionGroupId = createdGroup ? createdGroup.id : og.id;
+                if (!productOptionGroupId) {
+                    throw new Error('Could not get a productOptionGroupId');
+                }
+                return og.values
+                    .filter(v => !v.locked)
+                    .map(v => ({
+                        productOptionGroupId,
+                        code: normalizeString(v.name, '-'),
+                        translations: [{ name: v.name, languageCode: this.languageCode }],
+                    }));
+            })
+            .reduce((flat, options) => [...flat, ...options], []);
+
+        const allGroupIds = [
+            ...createdOptionGroups.map(g => g.id),
+            ...this.optionGroups.map(g => g.id).filter(notNullOrUndefined),
+        ];
+
+        if (newOptions.length) {
+            return forkJoin(newOptions.map(input => this.dataService.product.addOptionToGroup(input))).pipe(
+                map(() => allGroupIds),
+            );
+        } else {
+            return of(allGroupIds);
+        }
+    }
+
+    private fetchOptionGroups(groupsIds: string[]): Observable<ProductOptionGroupFragment[]> {
+        return forkJoin(
+            groupsIds.map(id =>
+                this.dataService.product
+                    .getProductOptionGroup(id)
+                    .mapSingle(data => data.productOptionGroup)
+                    .pipe(filter(notNullOrUndefined)),
+            ),
+        );
+    }
+
+    private createNewProductVariants(groups: ProductOptionGroupFragment[]) {
+        const options = groups
+            .filter(notNullOrUndefined)
+            .map(og => og.options)
+            .reduce((flat, o) => [...flat, ...o], []);
+        const variants = Object.values(this.variantFormValues)
+            .filter(v => v.enabled && !v.existing)
+            .map(v => ({
+                price: v.price,
+                sku: v.sku,
+                stock: v.stock,
+                optionIds: v.options
+                    .map(name => options.find(o => o.name === name))
+                    .filter(notNullOrUndefined)
+                    .map(o => o.id),
+            }));
+        return this.productDetailService.createProductVariants(
+            this.product,
+            variants,
+            options,
+            this.languageCode,
+        );
+    }
+
+    private deleteDefaultVariant<T>(input: T): Observable<T> {
+        if (this.product.variants.length === 1) {
+            // If the default single product variant has been replaced by multiple variants,
+            // delete the original default variant.
+            return this.dataService.product
+                .deleteProductVariant(this.product.variants[0].id)
+                .pipe(map(() => input));
+        } else {
+            return of(input);
+        }
+    }
+
+    private reFetchProduct<T>(input: T): Observable<T> {
+        // Re-fetch the Product to force an update to the view.
+        const id = this.route.snapshot.paramMap.get('id');
+        if (id) {
+            return this.dataService.product.getProduct(id).single$.pipe(map(() => input));
+        } else {
+            return of(input);
+        }
+    }
+
+    private initOptionsAndVariants() {
+        this.route.data
+            .pipe(
+                switchMap(data => data.entity as Observable<GetProductVariantOptions.Product>),
+                take(1),
+            )
+            .subscribe(product => {
+                this.product = product;
+                this.optionGroups = product.optionGroups.map(og => {
+                    return {
+                        id: og.id,
+                        isNew: false,
+                        name: og.name,
+                        values: og.options.map(o => ({
+                            id: o.id,
+                            name: o.name,
+                            locked: true,
+                        })),
+                    };
+                });
+                this.variantFormValues = this.getExistingVariants(product.variants);
+                this.generateVariants();
+            });
+    }
+
+    private getExistingVariants(
+        variants: GetProductVariantOptions.Variants[],
+    ): { [id: string]: VariantInfo } {
+        return variants.reduce((all, v) => {
+            const id = v.options.length ? v.options.map(o => o.name).join('|') : DEFAULT_VARIANT_CODE;
+            return {
+                ...all,
+                [id]: {
+                    productVariantId: v.id,
+                    enabled: true,
+                    existing: true,
+                    options: v.options.map(o => o.name),
+                    sku: v.sku,
+                    price: v.price,
+                    stock: v.stockOnHand,
+                },
+            };
+        }, {});
+    }
+}

+ 0 - 16
packages/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.html

@@ -33,22 +33,6 @@
                         <input type="checkbox" clrToggle name="enabled" formControlName="enabled" />
                         <label>{{ 'common.enabled' | translate }}</label>
                     </clr-toggle-wrapper>
-                    <vdr-dropdown>
-                        <button class="icon-button" vdrDropdownTrigger>
-                            <clr-icon shape="ellipsis-vertical"></clr-icon>
-                        </button>
-                        <vdr-dropdown-menu vdrPosition="bottom-right">
-                            <button
-                                type="button"
-                                class="delete-button"
-                                (click)="deleteVariantClick(variant.id)"
-                                vdrDropdownItem
-                            >
-                                <clr-icon shape="trash" class="is-danger"></clr-icon>
-                                {{ 'common.delete' | translate }}
-                            </button>
-                        </vdr-dropdown-menu>
-                    </vdr-dropdown>
                 </div>
             </div>
             <div class="card-block">

+ 0 - 5
packages/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts

@@ -50,7 +50,6 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     @Output() selectionChange = new EventEmitter<string[]>();
     @Output() selectFacetValueClick = new EventEmitter<string[]>();
     @Output() updateProductOption = new EventEmitter<UpdateProductOptionInput>();
-    @Output() deleteVariant = new EventEmitter<string>();
     selectedVariantIds: string[] = [];
     private facetValues: FacetValue.Fragment[];
     private formSubscription: Subscription;
@@ -170,10 +169,6 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
             });
     }
 
-    deleteVariantClick(id: string) {
-        this.deleteVariant.emit(id);
-    }
-
     private getFacetValueIds(index: number): string[] {
         const formValue: VariantFormValue = this.formArray.at(index).value;
         return formValue.facetValueIds;

+ 42 - 20
packages/admin-ui/src/app/catalog/providers/product-detail.service.ts

@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
 import { BehaviorSubject, forkJoin, Observable, of, throwError } from 'rxjs';
 import { map, mergeMap, shareReplay, skip, switchMap } from 'rxjs/operators';
 import { normalizeString } from 'shared/normalize-string';
+import { notNullOrUndefined } from 'shared/shared-utils';
 
 import {
     CreateProductInput,
@@ -9,6 +10,7 @@ import {
     DeletionResult,
     FacetWithValues,
     LanguageCode,
+    ProductOptionGroup,
     UpdateProductInput,
     UpdateProductMutation,
     UpdateProductOptionInput,
@@ -94,7 +96,7 @@ export class ProductDetailService {
                 );
             }),
             mergeMap(({ createProduct, optionGroups }) => {
-                const variants: CreateProductVariantInput[] = createVariantsConfig.variants.map(v => {
+                const variants = createVariantsConfig.variants.map(v => {
                     const optionIds = optionGroups.length
                         ? v.optionValues.map((optionName, index) => {
                               const option = optionGroups[index].options.find(o => o.name === optionName);
@@ -106,33 +108,53 @@ export class ProductDetailService {
                               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,
-                            },
-                        ],
+                        ...v,
                         optionIds,
                     };
                 });
-                return this.dataService.product.createProductVariants(variants).pipe(
-                    map(({ createProductVariants }) => ({
-                        createProductVariants,
-                        productId: createProduct.id,
-                    })),
-                );
+                const options = optionGroups.map(og => og.options).reduce((flat, o) => [...flat, ...o], []);
+                return this.createProductVariants(createProduct, variants, options, languageCode);
             }),
         );
     }
 
+    createProductVariants(
+        product: { name: string; id: string },
+        variantData: Array<{ price: number; sku: string; stock: number; optionIds: string[] }>,
+        options: Array<{ id: string; name: string }>,
+        languageCode: LanguageCode,
+    ) {
+        const variants: CreateProductVariantInput[] = variantData.map(v => {
+            const name = options.length
+                ? `${product.name} ${v.optionIds
+                      .map(id => options.find(o => o.id === id))
+                      .filter(notNullOrUndefined)
+                      .map(o => o.name)
+                      .join(' ')}`
+                : product.name;
+            return {
+                productId: product.id,
+                price: v.price,
+                sku: v.sku,
+                stockOnHand: v.stock,
+                translations: [
+                    {
+                        languageCode,
+                        name,
+                    },
+                ],
+                optionIds: v.optionIds,
+            };
+        });
+        return this.dataService.product.createProductVariants(variants).pipe(
+            map(({ createProductVariants }) => ({
+                createProductVariants,
+                productId: product.id,
+            })),
+        );
+    }
+
     updateProduct(productInput?: UpdateProductInput, variantInput?: UpdateProductVariantInput[]) {
         const updateOperations: Array<Observable<UpdateProductMutation | UpdateProductVariantsMutation>> = [];
         if (productInput) {

+ 21 - 0
packages/admin-ui/src/app/catalog/providers/routing/product-variants-resolver.ts

@@ -0,0 +1,21 @@
+import { Injectable } from '@angular/core';
+
+import { BaseEntityResolver } from '../../../common/base-entity-resolver';
+import { GetProductVariantOptions } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+
+@Injectable()
+export class ProductVariantsResolver extends BaseEntityResolver<GetProductVariantOptions.Product> {
+    constructor(private dataService: DataService) {
+        super(
+            {
+                __typename: 'Product' as 'Product',
+                id: '',
+                name: '',
+                optionGroups: [],
+                variants: [],
+            },
+            id => this.dataService.product.getProductVariantsOptions(id).mapStream(data => data.product),
+        );
+    }
+}

+ 43 - 0
packages/admin-ui/src/app/common/generated-types.ts

@@ -3879,6 +3879,20 @@ export type CreateProductOptionGroupMutationVariables = {
 
 export type CreateProductOptionGroupMutation = ({ __typename?: 'Mutation' } & { createProductOptionGroup: ({ __typename?: 'ProductOptionGroup' } & ProductOptionGroupFragment) });
 
+export type GetProductOptionGroupQueryVariables = {
+  id: Scalars['ID']
+};
+
+
+export type GetProductOptionGroupQuery = ({ __typename?: 'Query' } & { productOptionGroup: Maybe<({ __typename?: 'ProductOptionGroup' } & ProductOptionGroupFragment)> });
+
+export type AddOptionToGroupMutationVariables = {
+  input: CreateProductOptionInput
+};
+
+
+export type AddOptionToGroupMutation = ({ __typename?: 'Mutation' } & { createProductOption: ({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'name' | 'code' | 'groupId'>) });
+
 export type AddOptionGroupToProductMutationVariables = {
   productId: Scalars['ID'],
   optionGroupId: Scalars['ID']
@@ -3951,6 +3965,13 @@ export type DeleteProductVariantMutationVariables = {
 
 export type DeleteProductVariantMutation = ({ __typename?: 'Mutation' } & { deleteProductVariant: ({ __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>) });
 
+export type GetProductVariantOptionsQueryVariables = {
+  id: Scalars['ID']
+};
+
+
+export type GetProductVariantOptionsQuery = ({ __typename?: 'Query' } & { product: Maybe<({ __typename?: 'Product' } & Pick<Product, 'id' | 'name'> & { optionGroups: Array<({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'name' | 'code'> & { options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'name' | 'code'>)> })>, variants: Array<({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'enabled' | 'name' | 'sku' | 'price' | 'stockOnHand' | 'enabled'> & { options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'name' | 'code' | 'groupId'>)> })> })> });
+
 export type PromotionFragment = ({ __typename?: 'Promotion' } & Pick<Promotion, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled'> & { conditions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)>, actions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)> });
 
 export type GetPromotionListQueryVariables = {
@@ -4789,6 +4810,18 @@ export namespace CreateProductOptionGroup {
   export type CreateProductOptionGroup = ProductOptionGroupFragment;
 }
 
+export namespace GetProductOptionGroup {
+  export type Variables = GetProductOptionGroupQueryVariables;
+  export type Query = GetProductOptionGroupQuery;
+  export type ProductOptionGroup = ProductOptionGroupFragment;
+}
+
+export namespace AddOptionToGroup {
+  export type Variables = AddOptionToGroupMutationVariables;
+  export type Mutation = AddOptionToGroupMutation;
+  export type CreateProductOption = AddOptionToGroupMutation['createProductOption'];
+}
+
 export namespace AddOptionGroupToProduct {
   export type Variables = AddOptionGroupToProductMutationVariables;
   export type Mutation = AddOptionGroupToProductMutation;
@@ -4861,6 +4894,16 @@ export namespace DeleteProductVariant {
   export type DeleteProductVariant = DeleteProductVariantMutation['deleteProductVariant'];
 }
 
+export namespace GetProductVariantOptions {
+  export type Variables = GetProductVariantOptionsQueryVariables;
+  export type Query = GetProductVariantOptionsQuery;
+  export type Product = (NonNullable<GetProductVariantOptionsQuery['product']>);
+  export type OptionGroups = (NonNullable<(NonNullable<GetProductVariantOptionsQuery['product']>)['optionGroups'][0]>);
+  export type Options = (NonNullable<(NonNullable<(NonNullable<GetProductVariantOptionsQuery['product']>)['optionGroups'][0]>)['options'][0]>);
+  export type Variants = (NonNullable<(NonNullable<GetProductVariantOptionsQuery['product']>)['variants'][0]>);
+  export type _Options = (NonNullable<(NonNullable<(NonNullable<GetProductVariantOptionsQuery['product']>)['variants'][0]>)['options'][0]>);
+}
+
 export namespace Promotion {
   export type Fragment = PromotionFragment;
   export type Conditions = ConfigurableOperationFragment;

+ 54 - 0
packages/admin-ui/src/app/data/definitions/product-definitions.ts

@@ -189,6 +189,26 @@ export const CREATE_PRODUCT_OPTION_GROUP = gql`
     ${PRODUCT_OPTION_GROUP_FRAGMENT}
 `;
 
+export const GET_PRODUCT_OPTION_GROUP = gql`
+    query GetProductOptionGroup($id: ID!) {
+        productOptionGroup(id: $id) {
+            ...ProductOptionGroup
+        }
+    }
+    ${PRODUCT_OPTION_GROUP_FRAGMENT}
+`;
+
+export const ADD_OPTION_TO_GROUP = gql`
+    mutation AddOptionToGroup($input: CreateProductOptionInput!) {
+        createProductOption(input: $input) {
+            id
+            name
+            code
+            groupId
+        }
+    }
+`;
+
 export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
     mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
         addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
@@ -334,3 +354,37 @@ export const DELETE_PRODUCT_VARIANT = gql`
         }
     }
 `;
+
+export const GET_PRODUCT_VARIANT_OPTIONS = gql`
+    query GetProductVariantOptions($id: ID!) {
+        product(id: $id) {
+            id
+            name
+            optionGroups {
+                id
+                name
+                code
+                options {
+                    id
+                    name
+                    code
+                }
+            }
+            variants {
+                id
+                enabled
+                name
+                sku
+                price
+                stockOnHand
+                enabled
+                options {
+                    id
+                    name
+                    code
+                    groupId
+                }
+            }
+        }
+    }
+`;

+ 32 - 0
packages/admin-ui/src/app/data/providers/product-data.service.ts

@@ -2,18 +2,22 @@ import { pick } from 'shared/pick';
 
 import {
     AddOptionGroupToProduct,
+    AddOptionToGroup,
     CreateAssets,
     CreateProduct,
     CreateProductInput,
     CreateProductOptionGroup,
     CreateProductOptionGroupInput,
+    CreateProductOptionInput,
     CreateProductVariantInput,
     CreateProductVariants,
     DeleteProduct,
     DeleteProductVariant,
     GetAssetList,
     GetProductList,
+    GetProductOptionGroup,
     GetProductOptionGroups,
+    GetProductVariantOptions,
     GetProductWithVariants,
     Reindex,
     RemoveOptionGroupFromProduct,
@@ -29,6 +33,7 @@ import {
 import { getDefaultLanguage } from '../../common/utilities/get-default-language';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
+    ADD_OPTION_TO_GROUP,
     CREATE_ASSETS,
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
@@ -37,7 +42,9 @@ import {
     DELETE_PRODUCT_VARIANT,
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
+    GET_PRODUCT_OPTION_GROUP,
     GET_PRODUCT_OPTION_GROUPS,
+    GET_PRODUCT_VARIANT_OPTIONS,
     GET_PRODUCT_WITH_VARIANTS,
     REMOVE_OPTION_GROUP_FROM_PRODUCT,
     SEARCH_PRODUCTS,
@@ -85,6 +92,24 @@ export class ProductDataService {
         );
     }
 
+    getProductVariantsOptions(id: string) {
+        return this.baseDataService.query<GetProductVariantOptions.Query, GetProductVariantOptions.Variables>(
+            GET_PRODUCT_VARIANT_OPTIONS,
+            {
+                id,
+            },
+        );
+    }
+
+    getProductOptionGroup(id: string) {
+        return this.baseDataService.query<GetProductOptionGroup.Query, GetProductOptionGroup.Variables>(
+            GET_PRODUCT_OPTION_GROUP,
+            {
+                id,
+            },
+        );
+    }
+
     createProduct(product: CreateProductInput) {
         const input: CreateProduct.Variables = {
             input: pick(product, [
@@ -185,6 +210,13 @@ export class ProductDataService {
         >(ADD_OPTION_GROUP_TO_PRODUCT, variables);
     }
 
+    addOptionToGroup(input: CreateProductOptionInput) {
+        return this.baseDataService.mutate<AddOptionToGroup.Mutation, AddOptionToGroup.Variables>(
+            ADD_OPTION_TO_GROUP,
+            { input },
+        );
+    }
+
     removeOptionGroupFromProduct(variables: RemoveOptionGroupFromProduct.Variables) {
         return this.baseDataService.mutate<
             RemoveOptionGroupFromProduct.Mutation,

+ 4 - 0
packages/admin-ui/src/app/shared/components/currency-input/currency-input.component.scss

@@ -1,8 +1,12 @@
 :host {
     padding: 0;
+    border: none;
 }
 
 input {
     width: 100%;
     max-width: 96px;
+    &[readonly] {
+        background-color: transparent;
+    }
 }

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

@@ -12,6 +12,7 @@
     "dashboard": "Dashboard",
     "facets": "Facets",
     "global-settings": "Global settings",
+    "manage-variants": "Manage variants",
     "orders": "Orders",
     "payment-methods": "Payment methods",
     "products": "Products",
@@ -39,6 +40,7 @@
     "create-new-collection": "Create new collection",
     "create-new-facet": "Create new facet",
     "create-new-product": "New product",
+    "created-new-variants-success": "Successfully created {count} new {count, plural, one {variant} other {variants}}",
     "display-variant-cards": "View details",
     "display-variant-table": "View as table",
     "drop-files-to-upload": "Drop files to upload",
@@ -47,6 +49,7 @@
     "filters": "Filters",
     "group-by-product": "Group by product",
     "height": "Height",
+    "manage-variants": "Manage variants",
     "move-down": "Move down",
     "move-to": "Move to",
     "move-up": "Move up",
@@ -97,6 +100,7 @@
   "common": {
     "ID": "ID",
     "actions": "Actions",
+    "add-new-variants": "Add {count, plural, one {1 variant} other {{count} variants}}",
     "available-languages": "Available languages",
     "cancel": "Cancel",
     "cancel-navigation": "Cancel navigation",