Przeglądaj źródła

feat(admin-ui): Improve manage variants process

Michael Bromley 2 lat temu
rodzic
commit
609bc90dfa
29 zmienionych plików z 690 dodań i 704 usunięć
  1. 7 1
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  2. 31 15
      packages/admin-ui/src/lib/catalog/src/catalog.routes.ts
  3. 25 0
      packages/admin-ui/src/lib/catalog/src/components/create-product-option-group-dialog/create-product-option-group-dialog.component.html
  4. 0 0
      packages/admin-ui/src/lib/catalog/src/components/create-product-option-group-dialog/create-product-option-group-dialog.component.scss
  5. 51 0
      packages/admin-ui/src/lib/catalog/src/components/create-product-option-group-dialog/create-product-option-group-dialog.component.ts
  6. 45 0
      packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.html
  7. 0 0
      packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.scss
  8. 89 0
      packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.ts
  9. 12 18
      packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.html
  10. 46 10
      packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.ts
  11. 2 1
      packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.scss
  12. 8 7
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  13. 1 6
      packages/admin-ui/src/lib/catalog/src/components/product-variant-detail/product-variant-detail.component.html
  14. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-variant-detail/product-variant-detail.component.ts
  15. 111 134
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  16. 2 3
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.scss
  17. 190 380
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  18. 0 42
      packages/admin-ui/src/lib/catalog/src/providers/routing/product-resolver.ts
  19. 1 0
      packages/admin-ui/src/lib/catalog/src/providers/routing/product-variants-resolver.ts
  20. 0 2
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  21. 1 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  22. 3 0
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  23. 10 6
      packages/admin-ui/src/lib/core/src/shared/components/affixed-input/affixed-input.component.scss
  24. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.ts
  25. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/form-field/form-field.component.scss
  26. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.scss
  27. 40 10
      packages/admin-ui/src/lib/static/styles/global/_overrides.scss
  28. 11 10
      packages/admin-ui/src/lib/static/styles/global/_utilities.scss
  29. 0 57
      packages/admin-ui/src/lib/static/styles/theme/dark.scss

+ 7 - 1
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -60,6 +60,8 @@ import { ProductVariantsEditorComponent } from './components/product-variants-ed
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
 import { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.component';
 import { VariantPriceDetailComponent } from './components/variant-price-detail/variant-price-detail.component';
+import { CreateProductVariantDialogComponent } from './components/create-product-variant-dialog/create-product-variant-dialog.component';
+import { CreateProductOptionGroupDialogComponent } from './components/create-product-option-group-dialog/create-product-option-group-dialog.component';
 
 const CATALOG_COMPONENTS = [
     ProductListComponent,
@@ -97,7 +99,11 @@ const CATALOG_COMPONENTS = [
 @NgModule({
     imports: [SharedModule, RouterModule.forChild([])],
     exports: [...CATALOG_COMPONENTS],
-    declarations: [...CATALOG_COMPONENTS],
+    declarations: [
+        ...CATALOG_COMPONENTS,
+        CreateProductVariantDialogComponent,
+        CreateProductOptionGroupDialogComponent,
+    ],
     providers: [
         {
             provide: ROUTES,

+ 31 - 15
packages/admin-ui/src/lib/catalog/src/catalog.routes.ts

@@ -1,15 +1,15 @@
-import { Route } from '@angular/router';
+import { inject } from '@angular/core';
+import { ActivatedRouteSnapshot, Route } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    AssetFragment,
     CanDeactivateDetailGuard,
-    CollectionFragment,
     createResolveData,
-    detailBreadcrumb,
-    GetProductWithVariantsQuery,
+    DataService,
+    GetProfileDetailDocument,
     PageComponent,
     PageService,
 } from '@vendure/admin-ui/core';
+import { of } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { ProductOptionsEditorComponent } from './components/product-options-editor/product-options-editor.component';
 import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
@@ -32,7 +32,32 @@ export const createRoutes = (pageService: PageService): Route[] => [
             locationId: 'product-detail',
             breadcrumb: { label: _('breadcrumb.products'), link: ['../', 'products'] },
         },
-        children: pageService.getPageTabRoutes('product-detail'),
+        children: [
+            {
+                path: 'manage-variants',
+                component: ProductVariantsEditorComponent,
+                canDeactivate: [CanDeactivateDetailGuard],
+                data: {
+                    breadcrumb: ({ product }) => [
+                        {
+                            label: `${product.name}`,
+                            link: ['../'],
+                        },
+                        {
+                            label: _('breadcrumb.manage-variants'),
+                            link: ['manage-variants'],
+                        },
+                    ],
+                },
+                resolve: {
+                    product: (route: ActivatedRouteSnapshot) =>
+                        inject(DataService)
+                            .product.getProductVariantsOptions(route.parent?.params.id)
+                            .mapSingle(data => data.product),
+                },
+            },
+            ...pageService.getPageTabRoutes('product-detail'),
+        ],
     },
     {
         path: 'products/:productId/variants/:id',
@@ -43,15 +68,6 @@ export const createRoutes = (pageService: PageService): Route[] => [
         },
         children: pageService.getPageTabRoutes('product-variant-detail'),
     },
-    {
-        path: 'products/:id/manage-variants',
-        component: ProductVariantsEditorComponent,
-        resolve: createResolveData(ProductVariantsResolver),
-        canDeactivate: [CanDeactivateDetailGuard],
-        data: {
-            breadcrumb: productVariantEditorBreadcrumb,
-        },
-    },
     {
         path: 'products/:id/options',
         component: ProductOptionsEditorComponent,

+ 25 - 0
packages/admin-ui/src/lib/catalog/src/components/create-product-option-group-dialog/create-product-option-group-dialog.component.html

@@ -0,0 +1,25 @@
+<ng-template vdrDialogTitle>
+    {{ 'catalog.create-product-option-group' | translate }}
+</ng-template>
+<div class="form-grid" [formGroup]="form">
+    <vdr-form-field [label]="'common.name' | translate" for="name">
+        <input id="name" type="text" formControlName="name" (input)="updateCode()" />
+    </vdr-form-field>
+    <vdr-form-field
+        [label]="'common.code' | translate"
+        for="code"
+        [errors]="{ pattern: 'catalog.code-pattern-error' | translate }"
+    >
+        <input
+            id="code"
+            type="text"
+            formControlName="code"
+        />
+    </vdr-form-field>
+</div>
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit" (click)="confirm()" class="btn btn-primary" [disabled]="form.invalid">
+        {{ 'common.confirm' | translate }}
+    </button>
+</ng-template>

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


+ 51 - 0
packages/admin-ui/src/lib/catalog/src/components/create-product-option-group-dialog/create-product-option-group-dialog.component.ts

@@ -0,0 +1,51 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormBuilder, Validators } from '@angular/forms';
+import {
+    CreateProductOptionGroupInput,
+    Dialog,
+    findTranslation,
+    GetProductVariantOptionsQuery,
+    LanguageCode,
+    ServerConfigService,
+} from '@vendure/admin-ui/core';
+import { normalizeString } from '@vendure/common/lib/normalize-string';
+
+@Component({
+    selector: 'vdr-create-product-option-group-dialog',
+    templateUrl: './create-product-option-group-dialog.component.html',
+    styleUrls: ['./create-product-option-group-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CreateProductOptionGroupDialogComponent implements Dialog<CreateProductOptionGroupInput> {
+    resolveWith: (result?: CreateProductOptionGroupInput) => void;
+    languageCode: LanguageCode;
+    form = this.formBuilder.group({
+        name: ['', Validators.required],
+        code: ['', Validators.required],
+    });
+    constructor(private formBuilder: FormBuilder) {}
+
+    updateCode() {
+        const nameControl = this.form.get('name');
+        const codeControl = this.form.get('code');
+        if (nameControl && codeControl && codeControl.pristine) {
+            codeControl.setValue(normalizeString(`${nameControl.value}`, '-'));
+        }
+    }
+
+    confirm() {
+        const { name, code } = this.form.value;
+        if (!name || !code) {
+            return;
+        }
+        this.resolveWith({
+            code,
+            options: [],
+            translations: [{ languageCode: this.languageCode, name }],
+        });
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+}

+ 45 - 0
packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.html

@@ -0,0 +1,45 @@
+<ng-template vdrDialogTitle>
+    {{ 'catalog.create-product-variant' | translate }}
+</ng-template>
+<form [formGroup]="form">
+    <div formGroupName="options" class="form-grid">
+        <vdr-form-field [label]="optionGroup.name" *ngFor="let optionGroup of product.optionGroups">
+            <ng-select
+                [items]="optionGroup.options"
+                [formControlName]="optionGroup.code"
+                bindLabel="name"
+                bindValue="id"
+                appendTo="body"
+            >
+            </ng-select>
+        </vdr-form-field>
+    </div>
+    <div *ngIf="existingVariant" class="mt-2">
+        <clr-alert clrAlertType="warning" [clrAlertClosable]="false" class="">
+            <clr-alert-item>
+                <span class="alert-text">
+                    {{ 'catalog.product-variant-exists' | translate }}: {{ existingVariant.name }} ({{ existingVariant.sku }})
+                </span>
+            </clr-alert-item>
+        </clr-alert>
+    </div>
+    <div class="form-grid mt-2">
+        <vdr-form-field [label]="'common.name' | translate">
+            <input type="text" formControlName="name" />
+        </vdr-form-field>
+        <vdr-form-field [label]="'catalog.sku' | translate">
+            <input type="text" formControlName="sku" />
+        </vdr-form-field>
+    </div>
+</form>
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button
+        type="submit"
+        (click)="confirm()"
+        class="btn btn-primary"
+        [disabled]="form.invalid || existingVariant"
+    >
+        {{ 'common.confirm' | translate }}
+    </button>
+</ng-template>

+ 0 - 0
packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.scss


+ 89 - 0
packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.ts

@@ -0,0 +1,89 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormBuilder, FormControl, FormRecord, Validators } from '@angular/forms';
+import { CreateProductVariantInput, Dialog, GetProductVariantOptionsQuery } from '@vendure/admin-ui/core';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+
+@Component({
+    selector: 'vdr-create-product-variant-dialog',
+    templateUrl: './create-product-variant-dialog.component.html',
+    styleUrls: ['./create-product-variant-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CreateProductVariantDialogComponent implements Dialog<CreateProductVariantInput>, OnInit {
+    resolveWith: (result?: CreateProductVariantInput) => void;
+    product: NonNullable<GetProductVariantOptionsQuery['product']>;
+    form = this.formBuilder.group({
+        name: ['', Validators.required],
+        sku: ['', Validators.required],
+        options: this.formBuilder.record<string>({}),
+    });
+    existingVariant: NonNullable<GetProductVariantOptionsQuery['product']>['variants'][number] | undefined;
+
+    constructor(private formBuilder: FormBuilder) {}
+
+    ngOnInit() {
+        for (const optionGroup of this.product.optionGroups) {
+            (this.form.get('options') as FormRecord).addControl(
+                optionGroup.code,
+                new FormControl('', Validators.required),
+            );
+        }
+        const optionsRecord = this.form.get('options') as FormRecord;
+        optionsRecord.valueChanges.subscribe(value => {
+            const nameControl = this.form.get('name');
+            const allNull = Object.values(value).every(v => v == null);
+            if (!allNull && value && nameControl && !nameControl.dirty) {
+                const name = Object.entries(value)
+                    .map(
+                        ([groupCode, optionId]) =>
+                            this.product.optionGroups
+                                .find(og => og.code === groupCode)
+                                ?.options.find(o => o.id === optionId)?.name,
+                    )
+                    .join(' ');
+                nameControl.setValue(`${this.product.name} ${name}`);
+            }
+            const allSelected = Object.values(value).every(v => v != null);
+            if (allSelected) {
+                this.existingVariant = this.product.variants.find(v =>
+                    Object.entries(value).every(
+                        ([groupCode, optionId]) =>
+                            v.options.find(o => o.groupId === this.getGroupIdFromCode(groupCode))?.id ===
+                            optionId,
+                    ),
+                );
+            }
+        });
+    }
+
+    confirm() {
+        const { name, sku, options } = this.form.value;
+        if (!name || !sku || !options) {
+            return;
+        }
+        const optionIds = Object.values(options).filter(notNullOrUndefined);
+        this.resolveWith({
+            productId: this.product.id,
+            sku,
+            optionIds,
+            translations: [
+                {
+                    languageCode: this.product.languageCode,
+                    name,
+                },
+            ],
+        });
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+
+    private getGroupCodeFromId(id: string): string {
+        return this.product.optionGroups.find(og => og.id === id)?.code ?? '';
+    }
+
+    private getGroupIdFromCode(code: string): string {
+        return this.product.optionGroups.find(og => og.code === code)?.id ?? '';
+    }
+}

+ 12 - 18
packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.html

@@ -50,13 +50,13 @@
         </thead>
         <tr
             *ngFor="let variant of variants; trackBy: trackByFn"
-            [class.disabled]="!variantFormValues[variant.id].enabled"
+            [class.disabled]="!variantFormValues[variant.id].value.enabled === false"
+            [formGroup]="variantFormValues[variant.id]"
         >
             <td *ngIf="1 < variants.length">
                 <input
                     type="checkbox"
-                    (change)="onFormChange()"
-                    [(ngModel)]="variantFormValues[variant.id].enabled"
+                    formControlName="enabled"
                     clrCheckbox
                 />
             </td>
@@ -64,37 +64,31 @@
                 {{ variant.values.join(' ') }}
             </td>
             <td>
-                <clr-input-container>
+                <vdr-form-field>
                     <input
-                        clrInput
                         type="text"
-                        (change)="onFormChange()"
-                        [(ngModel)]="variantFormValues[variant.id].sku"
+                        formControlName="sku"
                         [placeholder]="'catalog.sku' | translate"
                     />
-                </clr-input-container>
+                </vdr-form-field>
             </td>
             <td>
-                <clr-input-container>
+                <vdr-form-field>
                     <vdr-currency-input
-                        clrInput
-                        [(ngModel)]="variantFormValues[variant.id].price"
-                        (ngModelChange)="onFormChange()"
+                        formControlName="price"
                         [currencyCode]="currencyCode"
                     ></vdr-currency-input>
-                </clr-input-container>
+                </vdr-form-field>
             </td>
             <td>
-                <clr-input-container>
+                <vdr-form-field>
                     <input
-                        clrInput
                         type="number"
-                        [(ngModel)]="variantFormValues[variant.id].stock"
-                        (change)="onFormChange()"
+                        formControlName="stock"
                         min="0"
                         step="1"
                     />
-                </clr-input-container>
+                </vdr-form-field>
             </td>
         </tr>
     </table>

+ 46 - 10
packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.ts

@@ -1,4 +1,5 @@
 import { Component, ElementRef, EventEmitter, OnInit, Output, QueryList, ViewChildren } from '@angular/core';
+import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
 import { CurrencyCode, DataService } from '@vendure/admin-ui/core';
 import { generateAllCombinations } from '@vendure/common/lib/shared-utils';
 
@@ -28,8 +29,16 @@ export class GenerateProductVariantsComponent implements OnInit {
     optionGroups: Array<{ name: string; values: Array<{ name: string; locked: boolean }> }> = [];
     currencyCode: CurrencyCode;
     variants: Array<{ id: string; values: string[] }>;
-    variantFormValues: { [id: string]: CreateVariantValues } = {};
-    constructor(private dataService: DataService) {}
+    variantFormValues: {
+        [id: string]: FormGroup<{
+            optionValues: FormControl<string[]>;
+            enabled: FormControl<boolean>;
+            price: FormControl<number>;
+            sku: FormControl<string>;
+            stock: FormControl<number>;
+        }>;
+    } = {};
+    constructor(private dataService: DataService, private formBuilder: FormBuilder) {}
 
     ngOnInit() {
         this.dataService.settings.getActiveChannel().single$.subscribe(data => {
@@ -60,18 +69,31 @@ export class GenerateProductVariantsComponent implements OnInit {
             : [[DEFAULT_VARIANT_CODE]];
         this.variants = generateAllCombinations(groups).map(values => ({ id: values.join('|'), values }));
 
-        this.variants.forEach(variant => {
+        this.variants.forEach((variant, index) => {
             if (!this.variantFormValues[variant.id]) {
-                this.variantFormValues[variant.id] = {
-                    optionValues: variant.values,
-                    enabled: true,
+                const formGroup = this.formBuilder.nonNullable.group({
+                    optionValues: [variant.values],
+                    enabled: true as boolean,
                     price: this.copyFromDefault(variant.id, 'price', 0),
                     sku: this.copyFromDefault(variant.id, 'sku', ''),
                     stock: this.copyFromDefault(variant.id, 'stock', 0),
-                };
+                });
+                formGroup.valueChanges.subscribe(() => this.onFormChange());
+                if (index === 0) {
+                    console.log(`registering valueChanges for ${variant.id}`);
+                    formGroup.get('price')?.valueChanges.subscribe(value => {
+                        this.copyValuesToPristine('price', formGroup.get('price'));
+                    });
+                    formGroup.get('sku')?.valueChanges.subscribe(value => {
+                        this.copyValuesToPristine('sku', formGroup.get('sku'));
+                    });
+                    formGroup.get('stock')?.valueChanges.subscribe(value => {
+                        this.copyValuesToPristine('stock', formGroup.get('stock'));
+                    });
+                }
+                this.variantFormValues[variant.id] = formGroup;
             }
         });
-        this.onFormChange();
     }
 
     trackByFn(index: number, variant: { name: string; values: string[] }) {
@@ -84,8 +106,22 @@ export class GenerateProductVariantsComponent implements OnInit {
         optionValueInputComponent.focus();
     }
 
+    copyValuesToPristine(field: 'price' | 'sku' | 'stock', formControl: AbstractControl | null) {
+        if (!formControl) {
+            return;
+        }
+        Object.values(this.variantFormValues).forEach(formGroup => {
+            const correspondingFormControl = formGroup.get(field) as FormControl;
+            if (correspondingFormControl && correspondingFormControl.pristine) {
+                correspondingFormControl.setValue(formControl.value, { emitEvent: false });
+            }
+        });
+    }
+
     onFormChange() {
-        const variantsToCreate = this.variants.map(v => this.variantFormValues[v.id]).filter(v => v.enabled);
+        const variantsToCreate = this.variants
+            .map(v => this.variantFormValues[v.id].value as CreateVariantValues)
+            .filter(v => v.enabled);
         this.variantsChange.emit({
             groups: this.optionGroups.map(og => ({ name: og.name, values: og.values.map(v => v.name) })),
             variants: variantsToCreate,
@@ -98,7 +134,7 @@ export class GenerateProductVariantsComponent implements OnInit {
         value: CreateVariantValues[T],
     ): CreateVariantValues[T] {
         return variantId !== DEFAULT_VARIANT_CODE
-            ? this.variantFormValues[DEFAULT_VARIANT_CODE][prop]
+            ? (this.variantFormValues[DEFAULT_VARIANT_CODE].get(prop)?.value as CreateVariantValues[T])
             : value;
     }
 }

+ 2 - 1
packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.scss

@@ -1,6 +1,6 @@
 
 .input-wrapper {
-    background-color: white;
+    background-color: var(--color-form-input-bg);
     border-radius: 3px !important;
     border: 1px solid var(--color-grey-300) !important;
     cursor: text;
@@ -22,6 +22,7 @@
         padding: 0 6px;
         &:focus {
             outline: none;
+            box-shadow: none;
         }
         &:disabled {
             background-color: var(--color-component-bg-100);

+ 8 - 7
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html

@@ -92,12 +92,8 @@
                     </button>
                 </div>
             </vdr-card>
-
-            <vdr-card>
-                <vdr-page-entity-info
-                    *ngIf="entity$ | async as entity"
-                    [entity]="entity"
-                ></vdr-page-entity-info>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity"></vdr-page-entity-info>
             </vdr-card>
         </vdr-page-detail-sidebar>
 
@@ -171,7 +167,7 @@
                 ></vdr-assets>
             </vdr-card>
 
-            <vdr-card [title]="'catalog.product-variants' | translate" [paddingX]="false">
+            <vdr-card [title]="'catalog.product-variants' | translate" [paddingX]="isNew$ | async">
                 <div class="card-span">
                     <div *ngIf="isNew$ | async; else variantList">
                         <vdr-generate-product-variants
@@ -184,6 +180,11 @@
                             [hideLanguageSelect]="true"
                         ></vdr-product-variant-list>
                     </ng-template>
+                    <div class="mx-3" *ngIf="(isNew$ | async) === false">
+                        <a class="button" [routerLink]="['manage-variants']">
+                            <clr-icon shape="add-text"></clr-icon>
+                            {{ 'catalog.manage-variants' | translate }}</a>
+                    </div>
                 </div>
             </vdr-card>
         </vdr-page-block>

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

@@ -53,15 +53,10 @@
                         (iconClick)="editOption(option)"
                         [icon]="(updatePermissions | hasPermission) && 'pencil'"
                     >
-                        <span class="mr-1">{{ optionGroupName(option.groupId) }}:</span>
+                        <span>{{ optionGroupName(option.groupId) }}:</span>
                         {{ optionName(option) }}
                     </vdr-chip>
                 </div>
-                <div>
-                    <a [routerLink]="['./', 'options']" class="btn"
-                        >{{ 'catalog.edit-options' | translate }}...</a
-                    >
-                </div>
             </vdr-card>
             <vdr-card [title]="'catalog.facets' | translate">
                 <div class="facets">

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-variant-detail/product-variant-detail.component.ts

@@ -310,7 +310,7 @@ export class ProductVariantDetailComponent
             price: variant.price,
             priceWithTax: variant.priceWithTax,
             taxCategoryId: variant.taxCategory.id,
-            stockOnHand: variant.stockLevels[0].stockOnHand,
+            stockOnHand: variant.stockLevels[0]?.stockOnHand ?? 0,
             useGlobalOutOfStockThreshold: variant.useGlobalOutOfStockThreshold,
             outOfStockThreshold: variant.outOfStockThreshold,
             trackInventory: variant.trackInventory,

+ 111 - 134
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html

@@ -1,136 +1,113 @@
-<vdr-action-bar>
-    <vdr-ab-right>
-        <button
-            class="btn btn-primary"
-            (click)="save()"
-            [disabled]="(!formValueChanged && !optionsChanged) || getVariantsToAdd().length === 0"
+<vdr-page-block>
+    <vdr-card>
+        <div *ngFor="let group of optionGroups; index as i" class="option-groups card-span">
+            <vdr-form-field [label]="'catalog.option' | translate">
+                <input clrInput [(ngModel)]="group.name" name="name" [readonly]="!group.isNew" />
+            </vdr-form-field>
+            <vdr-form-field [label]="'catalog.option-values' | translate" class="flex-spacer">
+                <vdr-option-value-input
+                    #optionValueInputComponent
+                    [options]="group.values"
+                    [groupName]="group.name"
+                    [disabled]="group.name === ''"
+                    (add)="addOption(i, $event.name)"
+                    (remove)="removeOption(i, $event)"
+                ></vdr-option-value-input>
+            </vdr-form-field>
+            <div>
+                <button
+                    [disabled]="group.locked"
+                    class="button-small mt-4"
+                    (click)="removeOptionGroup(group)"
+                >
+                    <clr-icon shape="trash"></clr-icon>
+                </button>
+            </div>
+        </div>
+        <div class="card-span">
+            <button class="btn btn-primary-outline btn-sm" (click)="addOptionGroup()">
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'catalog.create-product-option-group' | translate }}
+            </button>
+        </div>
+    </vdr-card>
+    <vdr-card [paddingX]="false">
+        <div class="card-span mx-3">
+            <button class="button" (click)="createNewVariant()">
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'catalog.create-new-variant' | translate }}
+            </button>
+        </div>
+        <vdr-data-table-2
+            class="card-span"
+            id="product-variant-list"
+            [items]="variants$ | async"
+            [itemsPerPage]="itemsPerPage"
+            [totalItems]="totalItems$ | async"
+            [currentPage]="currentPage"
+            (pageChange)="setPageNumber($event)"
+            (itemsPerPageChange)="setItemsPerPage($event)"
         >
-            {{ 'common.add-new-variants' | translate: { count: getVariantsToAdd().length } }}
-        </button>
-    </vdr-ab-right>
-</vdr-action-bar>
-
-<div *ngFor="let group of optionGroups; index as i" class="option-groups">
-    <div class="name">
-        <label>{{ 'catalog.option' | translate }}</label>
-        <input clrInput [(ngModel)]="group.name" name="name" [readonly]="!group.isNew" />
-    </div>
-    <div class="values">
-        <label>{{ 'catalog.option-values' | translate }}</label>
-        <vdr-option-value-input
-            #optionValueInputComponent
-            [options]="group.values"
-            [groupName]="group.name"
-            [disabled]="group.name === ''"
-            (add)="addOption(i, $event.name)"
-            (remove)="removeOption(i, $event)"
-        ></vdr-option-value-input>
-    </div>
-    <div>
-        <button
-            [disabled]="group.locked"
-            class="btn btn-icon btn-danger-outline mt5" (click)="removeOptionGroup(group)">
-            <clr-icon shape="trash"></clr-icon>
-        </button>
-    </div>
-</div>
-<button class="btn btn-primary-outline btn-sm" (click)="addOptionGroup()">
-    <clr-icon shape="plus"></clr-icon>
-    {{ 'catalog.add-option' | translate }}
-</button>
-
-<div class="variants-preview">
-    <table class="table">
-        <thead>
-            <tr>
-                <th></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 generatedVariants" [class.disabled]="!variant.enabled || variant.existing">
-            <td class="left">
-                <clr-checkbox-wrapper *ngIf="!variant.existing">
-                    <input
-                        type="checkbox"
-                        [(ngModel)]="variant.enabled"
-                        name="enabled"
-                        clrCheckbox
-                        (ngModelChange)="formValueChanged = true"
-                    />
-                    <label>{{ 'common.create' | translate }}</label>
-                </clr-checkbox-wrapper>
-            </td>
-            <td>
-                {{ getVariantName(variant) | translate }}
-            </td>
-            <td>
-                <div class="flex center">
-                    <clr-input-container *ngIf="!variant.existing">
-                        <input
-                            clrInput
-                            type="text"
-                            [(ngModel)]="variant.sku"
-                            [placeholder]="'catalog.sku' | translate"
-                            name="sku"
-                            required
-                            (ngModelChange)="onFormChanged(variant)"
-                        />
-                    </clr-input-container>
-                    <span *ngIf="variant.existing">{{ variant.sku }}</span>
-                </div>
-            </td>
-            <td>
-                <div class="flex center">
-                    <clr-input-container *ngIf="!variant.existing">
-                        <vdr-currency-input
-                            clrInput
-                            [(ngModel)]="variant.price"
-                            name="price"
-                            [currencyCode]="currencyCode"
-                            (ngModelChange)="onFormChanged(variant)"
-                        ></vdr-currency-input>
-                    </clr-input-container>
-                    <span *ngIf="variant.existing">{{ variant.price | localeCurrency: currencyCode }}</span>
-                </div>
-            </td>
-            <td>
-                <div class="flex center">
-                    <clr-input-container *ngIf="!variant.existing">
-                        <input
-                            clrInput
-                            type="number"
-                            [(ngModel)]="variant.stock"
-                            name="stock"
-                            min="0"
-                            step="1"
-                            (ngModelChange)="onFormChanged(variant)"
-                        />
-                    </clr-input-container>
-                    <span *ngIf="variant.existing">{{ variant.stock }}</span>
-                </div>
-            </td>
-            <td>
-                <vdr-dropdown *ngIf="variant.productVariantId as productVariantId">
-                    <button class="icon-button" vdrDropdownTrigger>
-                        <clr-icon shape="ellipsis-vertical"></clr-icon>
+            <vdr-bulk-action-menu
+                locationId="manage-variants-list"
+                [hostComponent]="this"
+                [selectionManager]="selectionManager"
+            />
+            <vdr-dt2-search
+                [searchTermControl]="searchTermControl"
+                [searchTermPlaceholder]="'catalog.filter-by-name' | translate"
+            />
+            <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true">
+                <ng-template let-variant="item">
+                    {{ variant.id }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'common.created-at' | translate" [hiddenByDefault]="true">
+                <ng-template let-variant="item">
+                    {{ variant.createdAt | localeDate : 'short' }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'common.updated-at' | translate" [hiddenByDefault]="true">
+                <ng-template let-variant="item">
+                    {{ variant.updatedAt | localeDate : 'short' }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'catalog.name' | translate" [optional]="false">
+                <ng-template let-variant="item">
+                    {{ variant.name }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'catalog.sku' | translate" [optional]="false">
+                <ng-template let-variant="item">
+                    {{ variant.sku }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column *ngFor="let optionGroup of optionGroups$ | async" [heading]="optionGroup.name">
+                <ng-template let-variant="item">
+                    <vdr-chip
+                        *ngIf="getOption(variant, optionGroup.id) as option"
+                        [colorFrom]="optionGroup.name"
+                        >{{ option.name }}</vdr-chip
+                    >
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'common.price' | translate" [hiddenByDefault]="true">
+                <ng-template let-variant="item">
+                    {{ variant.price | localeCurrency : variant.currencyCode }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'common.price-with-tax' | translate" [hiddenByDefault]="true">
+                <ng-template let-variant="item">
+                    {{ variant.priceWithTax | localeCurrency : variant.currencyCode }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'common.delete' | translate" [optional]="false">
+                <ng-template let-variant="item">
+                    <button class="button-small" (click)="deleteVariant(variant)">
+                        <clr-icon shape="trash is-danger"></clr-icon>
                     </button>
-                    <vdr-dropdown-menu vdrPosition="bottom-right">
-                        <button
-                            type="button"
-                            class="delete-button"
-                            (click)="deleteVariant(productVariantId, variant.options)"
-                            vdrDropdownItem
-                        >
-                            <clr-icon shape="trash" class="is-danger"></clr-icon>
-                            {{ 'common.delete' | translate }}
-                        </button>
-                    </vdr-dropdown-menu>
-                </vdr-dropdown>
-            </td>
-        </tr>
-    </table>
-</div>
+                </ng-template>
+            </vdr-dt2-column>
+        </vdr-data-table-2>
+    </vdr-card>
+</vdr-page-block>

+ 2 - 3
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.scss

@@ -1,9 +1,8 @@
 
 .option-groups {
     display: flex;
-    &:first-of-type {
-        margin-top: 24px;
-    }
+    width: 100%;
+    gap: var(--space-unit);
 }
 
 .values {

+ 190 - 380
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts

@@ -1,9 +1,8 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    CreateProductOptionGroupMutation,
-    CreateProductOptionInput,
     CurrencyCode,
     DataService,
     DeactivateAware,
@@ -13,17 +12,15 @@ import {
     LanguageCode,
     ModalService,
     NotificationService,
-    ProductOptionGroupWithOptionsFragment,
+    SelectionManager,
 } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
-import { pick } from '@vendure/common/lib/pick';
-import { generateAllCombinations, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { unique } from '@vendure/common/lib/unique';
-import { EMPTY, forkJoin, Observable, of } from 'rxjs';
-import { defaultIfEmpty, filter, map, mergeMap, switchMap } from 'rxjs/operators';
+import { EMPTY, Observable, Subject } from 'rxjs';
+import { map, startWith, switchMap } from 'rxjs/operators';
 
 import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
-import { ConfirmVariantDeletionDialogComponent } from '../confirm-variant-deletion-dialog/confirm-variant-deletion-dialog.component';
+import { CreateProductOptionGroupDialogComponent } from '../create-product-option-group-dialog/create-product-option-group-dialog.component';
+import { CreateProductVariantDialogComponent } from '../create-product-variant-dialog/create-product-variant-dialog.component';
 
 export class GeneratedVariant {
     isDefault: boolean;
@@ -48,7 +45,7 @@ interface OptionGroupUiModel {
     name: string;
     locked: boolean;
     values: Array<{
-        id?: string;
+        id: string;
         name: string;
         locked: boolean;
     }>;
@@ -63,10 +60,21 @@ interface OptionGroupUiModel {
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     formValueChanged = false;
     optionsChanged = false;
-    generatedVariants: GeneratedVariant[] = [];
     optionGroups: OptionGroupUiModel[];
     product: NonNullable<GetProductVariantOptionsQuery['product']>;
+    variants$: Observable<NonNullable<GetProductVariantOptionsQuery['product']>['variants']>;
+    optionGroups$: Observable<NonNullable<GetProductVariantOptionsQuery['product']>['optionGroups']>;
+    totalItems$: Observable<number>;
     currencyCode: CurrencyCode;
+    itemsPerPage = 100;
+    currentPage = 1;
+    searchTermControl = new FormControl('');
+    selectionManager = new SelectionManager<any>({
+        multiSelect: true,
+        itemsAreEqual: (a, b) => a.id === b.id,
+        additiveMode: true,
+    });
+    private refresh$ = new Subject<void>();
     private languageCode: LanguageCode;
 
     constructor(
@@ -78,12 +86,62 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     ) {}
 
     ngOnInit() {
-        this.initOptionsAndVariants();
         this.languageCode =
             (this.route.snapshot.paramMap.get('lang') as LanguageCode) || getDefaultUiLanguage();
         this.dataService.settings.getActiveChannel().single$.subscribe(data => {
             this.currencyCode = data.activeChannel.currencyCode;
         });
+
+        const product$ = this.refresh$.pipe(
+            switchMap(() =>
+                this.dataService.product
+                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                    .getProductVariantsOptions(this.route.parent?.snapshot.paramMap.get('id')!)
+                    .mapSingle(data => data.product),
+            ),
+            startWith(this.route.snapshot.data.product),
+        );
+
+        this.variants$ = product$.pipe(
+            switchMap(product =>
+                this.searchTermControl.valueChanges.pipe(
+                    startWith(''),
+                    map(term =>
+                        term
+                            ? product.variants.filter(v => v.name.toLowerCase().includes(term.toLowerCase()))
+                            : product.variants,
+                    ),
+                ),
+            ),
+        );
+        this.optionGroups$ = product$.pipe(map(product => product.optionGroups));
+        this.totalItems$ = this.variants$.pipe(map(variants => variants.length));
+
+        product$.subscribe(p => {
+            this.product = p;
+            const allUsedOptionIds = p.variants.map(v => v.options.map(option => option.id)).flat();
+            const allUsedOptionGroupIds = p.variants.map(v => v.options.map(option => option.groupId)).flat();
+            this.optionGroups = p.optionGroups.map(og => ({
+                id: og.id,
+                isNew: false,
+                name: og.name,
+                locked: allUsedOptionGroupIds.includes(og.id),
+                values: og.options.map(o => ({
+                    id: o.id,
+                    name: o.name,
+                    locked: allUsedOptionIds.includes(o.id),
+                })),
+            }));
+        });
+    }
+
+    setItemsPerPage(itemsPerPage: number) {
+        this.itemsPerPage = itemsPerPage;
+        this.currentPage = 1;
+    }
+
+    setPageNumber(page: number) {
+        this.currentPage = page;
     }
 
     onFormChanged(variantInfo: GeneratedVariant) {
@@ -95,37 +153,99 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         return !this.formValueChanged;
     }
 
-    getVariantsToAdd() {
-        return this.generatedVariants.filter(v => !v.existing && v.enabled);
+    addOptionGroup() {
+        this.modalService
+            .fromComponent(CreateProductOptionGroupDialogComponent, {
+                locals: {
+                    languageCode: this.languageCode,
+                },
+            })
+            .pipe(
+                switchMap(result => {
+                    if (result) {
+                        return this.dataService.product.createProductOptionGroups(result).pipe(
+                            switchMap(({ createProductOptionGroup }) =>
+                                this.dataService.product.addOptionGroupToProduct({
+                                    optionGroupId: createProductOptionGroup.id,
+                                    productId: this.product.id,
+                                }),
+                            ),
+                        );
+                    } else {
+                        return EMPTY;
+                    }
+                }),
+            )
+            .subscribe(result => {
+                this.notificationService.success(_('common.notify-create-success'), {
+                    entity: 'ProductOptionGroup',
+                });
+                this.refresh$.next();
+            });
     }
 
-    getVariantName(variant: GeneratedVariant) {
-        return variant.options.length === 0
-            ? _('catalog.default-variant')
-            : variant.options.map(o => o.name).join(' ');
+    removeOptionGroup(
+        optionGroup: NonNullable<GetProductVariantOptionsQuery['product']>['optionGroups'][number],
+    ) {
+        const id = optionGroup.id;
+        this.modalService
+            .dialog({
+                title: _('catalog.confirm-delete-product-option-group'),
+                translationVars: { name: optionGroup.name },
+                buttons: [
+                    { type: 'secondary', label: _('common.cancel') },
+                    { type: 'danger', label: _('common.delete'), returnValue: true },
+                ],
+            })
+            .pipe(
+                switchMap(val => {
+                    if (val) {
+                        return this.dataService.product.removeOptionGroupFromProduct({
+                            optionGroupId: id,
+                            productId: this.product.id,
+                        });
+                    } else {
+                        return EMPTY;
+                    }
+                }),
+            )
+            .subscribe(({ removeOptionGroupFromProduct }) => {
+                if (removeOptionGroupFromProduct.__typename === 'Product') {
+                    this.notificationService.success(_('common.notify-delete-success'), {
+                        entity: 'ProductOptionGroup',
+                    });
+                    this.refresh$.next();
+                } else if (removeOptionGroupFromProduct.__typename === 'ProductOptionInUseError') {
+                    this.notificationService.error(removeOptionGroupFromProduct.message ?? '');
+                }
+            });
     }
 
-    addOptionGroup() {
-        this.optionGroups.push({
-            isNew: true,
-            locked: false,
-            name: '',
-            values: [],
-        });
-        this.optionsChanged = true;
+    addOption(index: number, optionName: string) {
+        const group = this.optionGroups[index];
+        if (group && group.id) {
+            this.dataService.product
+                .addOptionToGroup({
+                    productOptionGroupId: group.id,
+                    code: normalizeString(optionName, '-'),
+                    translations: [{ name: optionName, languageCode: this.languageCode }],
+                })
+                .subscribe(({ createProductOption }) => {
+                    this.notificationService.success(_('common.notify-create-success'), {
+                        entity: 'ProductOption',
+                    });
+                    this.refresh$.next();
+                });
+        }
     }
 
-    removeOptionGroup(optionGroup: OptionGroupUiModel) {
-        const id = optionGroup.id;
-        if (optionGroup.isNew) {
-            this.optionGroups = this.optionGroups.filter(og => og !== optionGroup);
-            this.generateVariants();
-            this.optionsChanged = true;
-        } else if (id) {
+    removeOption(index: number, { id, name }: { id: string; name: string }) {
+        const optionGroup = this.optionGroups[index];
+        if (optionGroup) {
             this.modalService
                 .dialog({
-                    title: _('catalog.confirm-delete-product-option-group'),
-                    translationVars: { name: optionGroup.name },
+                    title: _('catalog.confirm-delete-product-option'),
+                    translationVars: { name },
                     buttons: [
                         { type: 'secondary', label: _('common.cancel') },
                         { type: 'danger', label: _('common.delete'), returnValue: true },
@@ -134,139 +254,31 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
                 .pipe(
                     switchMap(val => {
                         if (val) {
-                            return this.dataService.product.removeOptionGroupFromProduct({
-                                optionGroupId: id,
-                                productId: this.product.id,
-                            });
+                            return this.dataService.product.deleteProductOption(id);
                         } else {
                             return EMPTY;
                         }
                     }),
                 )
-                .subscribe(({ removeOptionGroupFromProduct }) => {
-                    if (removeOptionGroupFromProduct.__typename === 'Product') {
+                .subscribe(({ deleteProductOption }) => {
+                    if (deleteProductOption.result === DeletionResult.DELETED) {
                         this.notificationService.success(_('common.notify-delete-success'), {
-                            entity: 'ProductOptionGroup',
+                            entity: 'ProductOption',
                         });
-                        this.initOptionsAndVariants();
-                        this.optionsChanged = true;
-                    } else if (removeOptionGroupFromProduct.__typename === 'ProductOptionInUseError') {
-                        this.notificationService.error(removeOptionGroupFromProduct.message ?? '');
+                        optionGroup.values = optionGroup.values.filter(v => v.id !== id);
+                        this.refresh$.next();
+                    } else {
+                        this.notificationService.error(deleteProductOption.message ?? '');
                     }
                 });
         }
     }
 
-    addOption(index: number, optionName: string) {
-        const group = this.optionGroups[index];
-        if (group) {
-            group.values.push({ name: optionName, locked: false });
-            this.generateVariants();
-            this.optionsChanged = true;
-        }
-    }
-
-    removeOption(index: number, { id, name }: { id?: string; name: string }) {
-        const optionGroup = this.optionGroups[index];
-        if (optionGroup) {
-            if (!id) {
-                optionGroup.values = optionGroup.values.filter(v => v.name !== name);
-                this.generateVariants();
-            } else {
-                this.modalService
-                    .dialog({
-                        title: _('catalog.confirm-delete-product-option'),
-                        translationVars: { name },
-                        buttons: [
-                            { type: 'secondary', label: _('common.cancel') },
-                            { type: 'danger', label: _('common.delete'), returnValue: true },
-                        ],
-                    })
-                    .pipe(
-                        switchMap(val => {
-                            if (val) {
-                                return this.dataService.product.deleteProductOption(id);
-                            } else {
-                                return EMPTY;
-                            }
-                        }),
-                    )
-                    .subscribe(({ deleteProductOption }) => {
-                        if (deleteProductOption.result === DeletionResult.DELETED) {
-                            this.notificationService.success(_('common.notify-delete-success'), {
-                                entity: 'ProductOption',
-                            });
-                            optionGroup.values = optionGroup.values.filter(v => v.id !== id);
-                            this.generateVariants();
-                            this.optionsChanged = true;
-                        } else {
-                            this.notificationService.error(deleteProductOption.message ?? '');
-                        }
-                    });
-            }
-        }
-    }
-
-    generateVariants() {
-        const groups = this.optionGroups.map(g => g.values);
-        const previousVariants = this.generatedVariants;
-        const generatedVariantFactory = (
-            isDefault: boolean,
-            options: GeneratedVariant['options'],
-            existingVariant?: NonNullable<GetProductVariantOptionsQuery['product']>['variants'][number],
-            prototypeVariant?: NonNullable<GetProductVariantOptionsQuery['product']>['variants'][number],
-        ): GeneratedVariant => {
-            const prototype = this.getVariantPrototype(options, previousVariants);
-            return new GeneratedVariant({
-                enabled: true,
-                existing: !!existingVariant,
-                productVariantId: existingVariant?.id,
-                isDefault,
-                options,
-                price: existingVariant?.price ?? prototypeVariant?.price ?? prototype.price,
-                sku: existingVariant?.sku ?? prototypeVariant?.sku ?? prototype.sku,
-                stock: existingVariant?.stockOnHand ?? prototypeVariant?.stockOnHand ?? prototype.stock,
-            });
-        };
-        this.generatedVariants = groups.length
-            ? generateAllCombinations(groups).map(options => {
-                  const existingVariant = this.product.variants.find(v =>
-                      this.optionsAreEqual(v.options, options),
-                  );
-                  const prototypeVariant = this.product.variants.find(v =>
-                      this.optionsAreSubset(v.options, options),
-                  );
-                  return generatedVariantFactory(false, options, existingVariant, prototypeVariant);
-              })
-            : [generatedVariantFactory(true, [], this.product.variants[0])];
-    }
-
-    /**
-     * Returns one of the existing variants to base the newly-generated variant's
-     * details off.
-     */
-    private getVariantPrototype(
-        options: GeneratedVariant['options'],
-        previousVariants: GeneratedVariant[],
-    ): Pick<GeneratedVariant, 'sku' | 'price' | 'stock'> {
-        const variantsWithSimilarOptions = previousVariants.filter(v =>
-            options.map(o => o.name).filter(name => v.options.map(o => o.name).includes(name)),
-        );
-        if (variantsWithSimilarOptions.length) {
-            return pick(previousVariants[0], ['sku', 'price', 'stock']);
-        }
-        return {
-            sku: '',
-            price: 0,
-            stock: 0,
-        };
-    }
-
-    deleteVariant(id: string, options: GeneratedVariant['options']) {
+    deleteVariant(variant: NonNullable<GetProductVariantOptionsQuery['product']>['variants'][number]) {
         this.modalService
             .dialog({
                 title: _('catalog.confirm-delete-product-variant'),
-                translationVars: { name: options.map(o => o.name).join(' ') },
+                translationVars: { name: variant.name },
                 buttons: [
                     { type: 'secondary', label: _('common.cancel') },
                     { type: 'danger', label: _('common.delete'), returnValue: true },
@@ -274,16 +286,17 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             })
             .pipe(
                 switchMap(response =>
-                    response ? this.productDetailService.deleteProductVariant(id, this.product.id) : EMPTY,
+                    response
+                        ? this.productDetailService.deleteProductVariant(variant.id, this.product.id)
+                        : EMPTY,
                 ),
-                switchMap(() => this.reFetchProduct(null)),
             )
             .subscribe(
                 () => {
                     this.notificationService.success(_('common.notify-delete-success'), {
                         entity: 'ProductVariant',
                     });
-                    this.initOptionsAndVariants();
+                    this.refresh$.next();
                 },
                 err => {
                     this.notificationService.error(_('common.notify-delete-error'), {
@@ -293,237 +306,34 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             );
     }
 
-    save() {
-        this.optionGroups = this.optionGroups.filter(g => g.values.length);
-        const newOptionGroups = this.optionGroups
-            .filter(og => og.isNew)
-            .map(og => ({
-                name: og.name,
-                values: [],
-            }));
-
-        this.checkUniqueSkus()
-            .pipe(
-                mergeMap(() => this.confirmDeletionOfObsoleteVariants()),
-                mergeMap(() =>
-                    this.productDetailService.createProductOptionGroups(newOptionGroups, this.languageCode),
-                ),
-                mergeMap(createdOptionGroups => this.addOptionGroupsToProduct(createdOptionGroups)),
-                mergeMap(createdOptionGroups => this.addNewOptionsToGroups(createdOptionGroups)),
-                mergeMap(groupsIds => this.fetchOptionGroups(groupsIds)),
-                mergeMap(groups => this.createNewProductVariants(groups)),
-                mergeMap(res => this.deleteObsoleteVariants(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();
-                    this.optionsChanged = false;
+    createNewVariant() {
+        this.modalService
+            .fromComponent(CreateProductVariantDialogComponent, {
+                locals: {
+                    product: this.product,
                 },
-            });
-    }
-
-    private checkUniqueSkus() {
-        const withDuplicateSkus = this.generatedVariants.filter((variant, index) => (
-                variant.enabled &&
-                this.generatedVariants.find(gv => gv.sku.trim() === variant.sku.trim() && gv !== variant)
-            ));
-        if (withDuplicateSkus.length) {
-            return this.modalService
-                .dialog({
-                    title: _('catalog.duplicate-sku-warning'),
-                    body: unique(withDuplicateSkus.map(v => `${v.sku}`)).join(', '),
-                    buttons: [{ label: _('common.close'), returnValue: false, type: 'primary' }],
-                })
-                .pipe(mergeMap(res => EMPTY));
-        } else {
-            return of(true);
-        }
-    }
-
-    private confirmDeletionOfObsoleteVariants(): Observable<boolean> {
-        const obsoleteVariants = this.getObsoleteVariants();
-        if (obsoleteVariants.length) {
-            return this.modalService
-                .fromComponent(ConfirmVariantDeletionDialogComponent, {
-                    locals: {
-                        variants: obsoleteVariants,
-                    },
-                })
-                .pipe(
-                    mergeMap(res => res === true ? of(true) : EMPTY),
-                );
-        } else {
-            return of(true);
-        }
-    }
-
-    private getObsoleteVariants() {
-        return this.product.variants.filter(
-            variant => !this.generatedVariants.find(gv => gv.productVariantId === variant.id),
-        );
-    }
-
-    private hasOnlyDefaultVariant(product: NonNullable<GetProductVariantOptionsQuery['product']>): boolean {
-        return product.variants.length === 1 && product.optionGroups.length === 0;
-    }
-
-    private addOptionGroupsToProduct(
-        createdOptionGroups: Array<CreateProductOptionGroupMutation['createProductOptionGroup']>,
-    ): Observable<Array<CreateProductOptionGroupMutation['createProductOptionGroup']>> {
-        if (createdOptionGroups.length) {
-            return forkJoin(
-                createdOptionGroups.map(optionGroup => this.dataService.product.addOptionGroupToProduct({
-                        productId: this.product.id,
-                        optionGroupId: optionGroup.id,
-                    })),
-            ).pipe(map(() => createdOptionGroups));
-        } else {
-            return of([]);
-        }
-    }
-
-    private addNewOptionsToGroups(
-        createdOptionGroups: Array<CreateProductOptionGroupMutation['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<ProductOptionGroupWithOptionsFragment[]> {
-        return forkJoin(
-            groupsIds.map(id =>
-                this.dataService.product
-                    .getProductOptionGroup(id)
-                    .mapSingle(data => data.productOptionGroup)
-                    .pipe(filter(notNullOrUndefined)),
-            ),
-        ).pipe(defaultIfEmpty([] as ProductOptionGroupWithOptionsFragment[]));
-    }
-
-    private createNewProductVariants(groups: ProductOptionGroupWithOptionsFragment[]) {
-        const options = groups
-            .filter(notNullOrUndefined)
-            .map(og => og.options)
-            .reduce((flat, o) => [...flat, ...o], []);
-        const variants = this.generatedVariants
-            .filter(v => v.enabled && !v.existing)
-            .map(v => {
-                const optionIds = groups.map((group, index) => {
-                    const option = group.options.find(o => o.name === v.options[index].name);
-                    if (option) {
-                        return option.id;
+            .pipe(
+                switchMap(result => {
+                    if (result) {
+                        return this.dataService.product.createProductVariants([result]);
                     } else {
-                        throw new Error(`Could not find a matching option for group ${group.name}`);
+                        return EMPTY;
                     }
+                }),
+            )
+            .subscribe(result => {
+                this.notificationService.success(_('common.notify-create-success'), {
+                    entity: 'ProductVariant',
                 });
-                return {
-                    price: v.price,
-                    sku: v.sku,
-                    stock: v.stock,
-                    optionIds,
-                };
-            });
-        return this.productDetailService.createProductVariants(
-            this.product,
-            variants,
-            options,
-            this.languageCode,
-        );
-    }
-
-    private deleteObsoleteVariants<T>(input: T): Observable<T | T[]> {
-        const obsoleteVariants = this.getObsoleteVariants();
-        if (obsoleteVariants.length) {
-            const deleteOperations = obsoleteVariants.map(v =>
-                this.dataService.product.deleteProductVariant(v.id).pipe(map(() => input)),
-            );
-            return forkJoin(...deleteOperations);
-        } 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);
-        }
-    }
-
-    initOptionsAndVariants() {
-        this.dataService.product
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            .getProductVariantsOptions(this.route.snapshot.paramMap.get('id')!)
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            .mapSingle(({ product }) => product!)
-            .subscribe(p => {
-                this.product = p;
-                const allUsedOptionIds = p.variants.map(v => v.options.map(option => option.id)).flat();
-                const allUsedOptionGroupIds = p.variants
-                    .map(v => v.options.map(option => option.groupId))
-                    .flat();
-                this.optionGroups = p.optionGroups.map(og => ({
-                        id: og.id,
-                        isNew: false,
-                        name: og.name,
-                        locked: allUsedOptionGroupIds.includes(og.id),
-                        values: og.options.map(o => ({
-                            id: o.id,
-                            name: o.name,
-                            locked: allUsedOptionIds.includes(o.id),
-                        })),
-                    }));
-                this.generateVariants();
+                this.refresh$.next();
             });
     }
 
-    private optionsAreEqual(a: Array<{ name: string }>, b: Array<{ name: string }>): boolean {
-        return this.toOptionString(a) === this.toOptionString(b);
-    }
-
-    private optionsAreSubset(a: Array<{ name: string }>, b: Array<{ name: string }>): boolean {
-        return this.toOptionString(b).includes(this.toOptionString(a));
-    }
-
-    private toOptionString(o: Array<{ name: string }>): string {
-        return o
-            .map(x => x.name)
-            .sort()
-            .join('|');
+    getOption(
+        variant: NonNullable<GetProductVariantOptionsQuery['product']>['variants'][number],
+        groupId: string,
+    ) {
+        return variant.options.find(o => o.groupId === groupId);
     }
 }

+ 0 - 42
packages/admin-ui/src/lib/catalog/src/providers/routing/product-resolver.ts

@@ -1,42 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import {
-    BaseEntityResolver,
-    DataService,
-    getDefaultUiLanguage,
-    GetProductWithVariantsQuery,
-} from '@vendure/admin-ui/core';
-
-@Injectable({
-    providedIn: 'root',
-})
-export class ProductResolver extends BaseEntityResolver<GetProductWithVariantsQuery['product']> {
-    constructor(dataService: DataService, router: Router) {
-        super(
-            router,
-            {
-                __typename: 'Product' as 'Product',
-                id: '',
-                createdAt: '',
-                updatedAt: '',
-                enabled: true,
-                languageCode: getDefaultUiLanguage(),
-                name: '',
-                slug: '',
-                featuredAsset: null,
-                assets: [],
-                description: '',
-                translations: [],
-                optionGroups: [],
-                facetValues: [],
-                variantList: { items: [], totalItems: 0 },
-                channels: [],
-            },
-            id =>
-                dataService.product
-                    .getProduct(id, { take: 10 })
-                    .refetchOnChannelChange()
-                    .mapStream(data => data.product),
-        );
-    }
-}

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

@@ -17,6 +17,7 @@ export class ProductVariantsResolver extends BaseEntityResolver<GetProductVarian
                 createdAt: '',
                 updatedAt: '',
                 name: '',
+                languageCode: '' as any,
                 optionGroups: [],
                 variants: [],
             },

+ 0 - 2
packages/admin-ui/src/lib/catalog/src/public_api.ts

@@ -43,5 +43,3 @@ export * from './components/update-product-option-dialog/update-product-option-d
 export * from './components/variant-price-detail/variant-price-detail.component';
 export * from './providers/product-detail/product-detail.service';
 export * from './providers/product-detail/replace-last';
-export * from './providers/routing/product-resolver';
-export * from './providers/routing/product-variants-resolver';

Plik diff jest za duży
+ 1 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 3 - 0
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -623,6 +623,7 @@ export const GET_PRODUCT_VARIANT_OPTIONS = gql`
             createdAt
             updatedAt
             name
+            languageCode
             optionGroups {
                 ...ProductOptionGroup
                 options {
@@ -637,6 +638,8 @@ export const GET_PRODUCT_VARIANT_OPTIONS = gql`
                 name
                 sku
                 price
+                priceWithTax
+                currencyCode
                 stockOnHand
                 enabled
                 options {

+ 10 - 6
packages/admin-ui/src/lib/core/src/shared/components/affixed-input/affixed-input.component.scss

@@ -20,9 +20,11 @@
     transition: border 0.2s;
 }
 
-::ng-deep .has-prefix input {
-    border-top-left-radius: 0 !important;
-    border-bottom-left-radius: 0 !important
+::ng-deep .has-prefix > {
+    input[type="text"], input[type="number"] {
+        border-top-left-radius: 0 !important;
+        border-bottom-left-radius: 0 !important
+    }
 }
 
 .prefix {
@@ -31,9 +33,11 @@
     border-right: none;
 }
 
-::ng-deep .has-suffix input {
-    border-top-right-radius: 0 !important;
-    border-bottom-right-radius: 0 !important;
+::ng-deep .has-suffix > {
+    input[type="text"], input[type="number"] {
+        border-top-right-radius: 0 !important;
+        border-bottom-right-radius: 0 !important;
+    }
 }
 
 .suffix {

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.ts

@@ -225,6 +225,7 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnIn
             this.bulkActionMenuComponent.onClearSelection(() => {
                 this.changeDetectorRef.markForCheck();
             });
+            this.selectionManager.setCurrentItems(this.items);
         }
         this.showSearchFilterRow = dataTableConfig?.[this.id]?.showSearchFilterRow ?? false;
     }

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/form-field/form-field.component.scss

@@ -32,6 +32,7 @@
     textarea,
     vdr-zone-selector,
     vdr-facet-value-selector,
+    vdr-option-value-input,
     vdr-rich-text-editor {
         width: 100%;
     }

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.scss

@@ -10,4 +10,5 @@
 .modal-body {
     display: flex;
     flex-direction: column;
+    container-type: inline-size;
 }

+ 40 - 10
packages/admin-ui/src/lib/static/styles/global/_overrides.scss

@@ -1,5 +1,5 @@
-@import "variables";
-@import "forms";
+@import 'variables';
+@import 'forms';
 
 .main-container {
     background-color: var(--clr-global-app-background);
@@ -14,28 +14,33 @@ h6:not([cds-text]) {
     font-family: Inter, sans-serif !important;
 }
 
-a:link, a:visited {
+a:link,
+a:visited {
     color: var(--clr-global-link-color);
     text-decoration: none;
 }
 
-.content-area img, .modal-content img {
+.content-area img,
+.modal-content img {
     object-fit: cover;
     width: 100%;
     height: 100%;
 }
 
-.modal-header .close, .modal-header--accessible .close {
+.modal-header .close,
+.modal-header--accessible .close {
     border: none;
     background: none;
     cursor: pointer;
 }
 
-a:link, a:visited {
+a:link,
+a:visited {
     color: var(--clr-btn-link-color);
 }
 
-a:focus, button:focus {
+a:focus,
+button:focus {
     outline-color: var(--color-primary-400);
 }
 
@@ -55,8 +60,9 @@ a:focus, button:focus {
     border-color: var(--color-component-border-100);
     margin-top: 0;
 
-    td.align-middle, th.align-middle {
-        vertical-align: middle!important;
+    td.align-middle,
+    th.align-middle {
+        vertical-align: middle !important;
     }
 
     td.right {
@@ -64,7 +70,8 @@ a:focus, button:focus {
     }
 }
 
-.full-label, .compact-label {
+.full-label,
+.compact-label {
     margin-left: 6px;
 }
 .full-label {
@@ -100,3 +107,26 @@ button.icon-button {
 .btn.btn-link.nav-link {
     background-color: transparent;
 }
+
+.alert {
+    border: 1px solid var(--color-primary-150);
+    background-color: var(--color-primary-100);
+    color: var(--color-primary-900);
+    border-radius: var(--border-radius);
+    padding: var(--space-unit);
+    .alert-item {
+        display: flex;
+        align-items: flex-start;
+        gap: 4px;
+    }
+    &.alert-danger {
+        border-color: var(--color-chip-error-border);
+        background-color: var(--color-chip-error-bg);
+        color: var(--color-chip-error-text);
+    }
+    &.alert-warning {
+        border-color: var(--color-chip-warning-border);
+        background-color: var(--color-chip-warning-bg);
+        color: var(--color-chip-warning-text);
+    }
+}

+ 11 - 10
packages/admin-ui/src/lib/static/styles/global/_utilities.scss

@@ -20,6 +20,15 @@
     flex: 1;
 }
 
+.form-grid {
+    display: grid;
+    gap: calc(var(--space-unit) * 4);
+    grid-template-columns: 1fr 1fr;
+    @container (max-width: 500px) {
+        grid-template-columns: 1fr;
+    }
+}
+
 .max-w-layout {
     max-width: var(--layout-content-max-width);
 }
@@ -47,7 +56,6 @@
     margin-right: auto;
 }
 
-
 $spacings: (1, 2, 3, 4, 5, auto);
 
 $sides: (
@@ -133,15 +141,8 @@ $breakpoint-glue: '\\:' !default;
 }
 
 // colour classes
-$colors: (
-    'weight',
-    'primary',
-    'secondary',
-    'success',
-    'warning',
-    'error'
-);
-$scale: (100,  125,  150,  200,  300,  400,  500,  600,  700,  800,  900,  950,  975,  1000);
+$colors: ('weight', 'primary', 'secondary', 'success', 'warning', 'error');
+$scale: (100, 125, 150, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975, 1000);
 $properties: (
     'color': 'color',
     'bg': 'background-color',

+ 0 - 57
packages/admin-ui/src/lib/static/styles/theme/dark.scss

@@ -129,63 +129,6 @@
     --clr-theme-alert-font-color: hsl(210, 16%, 93%);
     --clr-theme-app-alert-font-color: hsl(0, 0%, 0%);
 
-    // Info type
-    --clr-alert-info-bg-color: hsl(198, 79%, 28%);
-    --clr-alert-info-font-color: var(--clr-theme-alert-font-color);
-    --clr-alert-info-border-color: transparent;
-    --clr-alert-info-icon-color: var(--clr-theme-alert-font-color);
-
-    // Success type
-    --clr-alert-success-bg-color: hsl(122, 45%, 23%);
-    --clr-alert-success-font-color: var(--clr-theme-alert-font-color);
-    --clr-alert-success-border-color: transparent;
-    --clr-alert-success-icon-color: var(--clr-theme-alert-font-color);
-
-    // Danger type
-    --clr-alert-danger-bg-color: hsl(357, 50%, 35%);
-    --clr-alert-danger-font-color: var(--clr-theme-alert-font-color);
-    --clr-alert-danger-border-color: transparent;
-    --clr-alert-danger-icon-color: var(--clr-theme-alert-font-color);
-
-    // Warning type
-    --clr-alert-warning-bg-color: hsl(47, 87%, 27%);
-    --clr-alert-warning-font-color: var(--clr-theme-alert-font-color);
-    --clr-alert-warning-border-color: transparent;
-    --clr-alert-warning-icon-color: var(--clr-theme-alert-font-color);
-
-    // App Info type
-    --clr-app-alert-info-bg-color: hsl(198, 65%, 57%);
-    --clr-app-alert-info-font-color: var(--clr-theme-app-alert-font-color);
-    --clr-app-alert-info-border-color: transparent;
-    --clr-app-alert-info-icon-color: var(--clr-theme-app-alert-font-color);
-
-    // App warning type
-    --clr-app-alert-warning-bg-color: hsl(49, 98%, 51%);
-    --clr-app-alert-warning-icon-color: var(--clr-theme-app-alert-font-color);
-    --clr-app-alert-warning-font-color: var(--clr-theme-app-alert-font-color);
-    --clr-app-alert-warning-border-color: transparent;
-
-    // App danger type
-    --clr-app-alert-danger-bg-color: hsl(3, 90%, 62%);
-    --clr-app-alert-danger-icon-color: var(--clr-theme-app-alert-font-color);
-    --clr-app-alert-danger-font-color: var(--clr-theme-app-alert-font-color);
-    --clr-app-alert-danger-border-color: transparent;
-
-    --clr-alert-action-color: hsl(0, 0%, 100%); // Used for dropdowns on the right side of an alert
-    --clr-alert-action-active-color: hsl(0, 0%, 100%); // Alert dropdowns when they are clicked on
-    --clr-app-alert-close-icon-color: var(--clr-close-color--normal); // Colors for the 'X' close btn in global alerts
-
-    --clr-alert-close-icon-opacity: 1;
-    --clr-alert-close-icon-hover-opacity: 1;
-
-    // Close icon colors for APP-LEVEL ALERTS
-    --clr-app-level-alert-color: hsl(0, 0%, 0%);
-    --clr-app-alert-close-icon-color: hsl(0, 0%, 0%);
-
-    /**********
-    * END: Alerts
-    */
-
     /*****************
     * Badge
     */

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików