Browse Source

feat(admin-ui): Implement pagination & filtering of product variants

Closes #411
Michael Bromley 5 years ago
parent
commit
e2b445b67a

+ 11 - 11
packages/admin-ui/i18n-coverage.json

@@ -1,34 +1,34 @@
 {
-  "generatedOn": "2020-07-28T15:41:10.262Z",
-  "lastCommit": "2f4760e74e7b14caf171772e03c3095212eb17bc",
+  "generatedOn": "2020-08-24T10:59:42.914Z",
+  "lastCommit": "6efa98be6c5120f7d12f2ef5662edabb40725bd9",
   "translationStatus": {
     "de": {
-      "tokenCount": 661,
-      "translatedCount": 609,
+      "tokenCount": 662,
+      "translatedCount": 610,
       "percentage": 92
     },
     "en": {
-      "tokenCount": 661,
-      "translatedCount": 660,
+      "tokenCount": 662,
+      "translatedCount": 662,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 661,
+      "tokenCount": 662,
       "translatedCount": 467,
       "percentage": 71
     },
     "pl": {
-      "tokenCount": 661,
+      "tokenCount": 662,
       "translatedCount": 566,
-      "percentage": 86
+      "percentage": 85
     },
     "zh_Hans": {
-      "tokenCount": 661,
+      "tokenCount": 662,
       "translatedCount": 550,
       "percentage": 83
     },
     "zh_Hant": {
-      "tokenCount": 661,
+      "tokenCount": 662,
       "translatedCount": 550,
       "percentage": 83
     }

+ 18 - 3
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html

@@ -45,7 +45,12 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<form class="form" [formGroup]="detailForm" *ngIf="product$ | async as product" (keydown.enter)="$event.preventDefault()">
+<form
+    class="form"
+    [formGroup]="detailForm"
+    *ngIf="product$ | async as product"
+    (keydown.enter)="$event.preventDefault()"
+>
     <clr-tabs>
         <clr-tab>
             <button clrTabLink (click)="navigateToTab('details')">
@@ -165,7 +170,7 @@
             <clr-tab-content *clrIfActive="(activeTab$ | async) === 'variants'">
                 <section class="form-block">
                     <div class="view-mode">
-                        <div class="btn-group btn-sm">
+                        <div class="btn-group">
                             <button
                                 class="btn btn-secondary-outline"
                                 (click)="variantDisplayMode = 'card'"
@@ -183,11 +188,21 @@
                                 {{ 'catalog.display-variant-table' | translate }}
                             </button>
                         </div>
+                        <div class="variant-filter">
+                            <input
+                                [formControl]="filterInput"
+                                class="clr-input"
+                                [placeholder]="'catalog.filter-by-name-or-sku' | translate"
+                            />
+                            <button class="icon-button" (click)="filterInput.setValue('')">
+                                <clr-icon shape="times"></clr-icon>
+                            </button>
+                        </div>
                         <div class="flex-spacer"></div>
                         <a
                             *vdrIfPermissions="'UpdateCatalog'"
                             [routerLink]="['./', 'manage-variants']"
-                            class="btn btn-secondary btn-sm edit-variants-btn"
+                            class="btn btn-secondary edit-variants-btn"
                         >
                             <clr-icon shape="add-text"></clr-icon>
                             {{ 'catalog.manage-variants' | translate }}

+ 16 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -12,6 +12,21 @@ vdr-action-bar clr-toggle-wrapper {
     margin-top: 12px;
 }
 
+.variant-filter {
+    flex: 1;
+    display: flex;
+    input {
+        flex: 1;
+        max-width: initial;
+        border-radius: 3px 0 0 3px !important;
+    }
+    .icon-button {
+        border: 1px solid $color-grey-300;
+        border-radius: 0 3px 3px 0;
+        border-left: none;
+    }
+}
+
 .group-name {
     padding-right: 6px;
 }
@@ -19,6 +34,7 @@ vdr-action-bar clr-toggle-wrapper {
 .view-mode {
     display: flex;
     justify-content: flex-end;
+    align-items: center;
 }
 
 .edit-variants-btn {

+ 49 - 27
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -1,6 +1,6 @@
 import { Location } from '@angular/common';
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
@@ -8,11 +8,15 @@ import {
     CreateProductInput,
     createUpdatedTranslatable,
     CustomFieldConfig,
+    DataService,
     FacetWithValues,
     flattenFacetValues,
     IGNORE_CAN_DEACTIVATE_GUARD,
     LanguageCode,
+    ModalService,
+    NotificationService,
     ProductWithVariants,
+    ServerConfigService,
     TaxCategory,
     UpdateProductInput,
     UpdateProductMutation,
@@ -20,16 +24,17 @@ import {
     UpdateProductVariantInput,
     UpdateProductVariantsMutation,
 } from '@vendure/admin-ui/core';
-import { DataService, ModalService, NotificationService, ServerConfigService } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
 import { combineLatest, EMPTY, merge, Observable } from 'rxjs';
 import {
+    debounceTime,
     distinctUntilChanged,
     map,
     mergeMap,
+    startWith,
     switchMap,
     take,
     takeUntil,
@@ -44,6 +49,7 @@ import { VariantAssetChange } from '../product-variants-list/product-variants-li
 
 export type TabName = 'details' | 'variants';
 export interface VariantFormValue {
+    id: string;
     enabled: boolean;
     sku: string;
     name: string;
@@ -79,6 +85,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     customOptionGroupFields: CustomFieldConfig[];
     customOptionFields: CustomFieldConfig[];
     detailForm: FormGroup;
+    filterInput = new FormControl('');
     assetChanges: SelectedAssets = {};
     variantAssetChanges: { [variantId: string]: SelectedAssets } = {};
     productChannels$: Observable<ProductWithVariants.Channels[]>;
@@ -123,21 +130,35 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     ngOnInit() {
         this.init();
         this.product$ = this.entity$;
-        this.variants$ = this.product$.pipe(map((product) => product.variants));
+        const variants$ = this.product$.pipe(map(product => product.variants));
+        const filterTerm$ = this.filterInput.valueChanges.pipe(startWith(''), debounceTime(50));
+        this.variants$ = combineLatest(variants$, filterTerm$).pipe(
+            map(([variants, term]) => {
+                return term
+                    ? variants.filter(v => {
+                          const lcTerm = term.toLocaleLowerCase();
+                          return (
+                              v.name.toLocaleLowerCase().includes(term) ||
+                              v.sku.toLocaleLowerCase().includes(term)
+                          );
+                      })
+                    : variants;
+            }),
+        );
         this.taxCategories$ = this.productDetailService.getTaxCategories().pipe(takeUntil(this.destroy$));
-        this.activeTab$ = this.route.paramMap.pipe(map((qpm) => qpm.get('tab') as any));
+        this.activeTab$ = this.route.paramMap.pipe(map(qpm => qpm.get('tab') as any));
 
         // FacetValues are provided initially by the nested array of the
         // Product entity, but once a fetch to get all Facets is made (as when
         // opening the FacetValue selector modal), then these additional values
         // are concatenated onto the initial array.
         this.facets$ = this.productDetailService.getFacets();
-        const productFacetValues$ = this.product$.pipe(map((product) => product.facetValues));
+        const productFacetValues$ = this.product$.pipe(map(product => product.facetValues));
         const allFacetValues$ = this.facets$.pipe(map(flattenFacetValues));
         const productGroup = this.getProductFormGroup();
 
         const formFacetValueIdChanges$ = productGroup.valueChanges.pipe(
-            map((val) => val.facetValueIds as string[]),
+            map(val => val.facetValueIds as string[]),
             distinctUntilChanged(),
         );
         const formChangeFacetValues$ = combineLatest(
@@ -147,12 +168,12 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         ).pipe(
             map(([ids, productFacetValues, allFacetValues]) => {
                 const combined = [...productFacetValues, ...allFacetValues];
-                return ids.map((id) => combined.find((fv) => fv.id === id)).filter(notNullOrUndefined);
+                return ids.map(id => combined.find(fv => fv.id === id)).filter(notNullOrUndefined);
             }),
         );
 
         this.facetValues$ = merge(productFacetValues$, formChangeFacetValues$);
-        this.productChannels$ = this.product$.pipe(map((p) => p.channels));
+        this.productChannels$ = this.product$.pipe(map(p => p.channels));
     }
 
     ngOnDestroy() {
@@ -194,7 +215,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                 ],
             })
             .pipe(
-                switchMap((response) =>
+                switchMap(response =>
                     response
                         ? this.dataService.product.removeProductsFromChannel({
                               channelId,
@@ -207,7 +228,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                 () => {
                     this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
                 },
             );
@@ -233,7 +254,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
      * If creating a new product, automatically generate the slug based on the product name.
      */
     updateSlug(nameValue: string) {
-        this.isNew$.pipe(take(1)).subscribe((isNew) => {
+        this.isNew$.pipe(take(1)).subscribe(isNew => {
             if (isNew) {
                 const slugControl = this.detailForm.get(['product', 'slug']);
                 if (slugControl && slugControl.pristine) {
@@ -244,7 +265,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     }
 
     selectProductFacetValue() {
-        this.displayFacetValueModal().subscribe((facetValueIds) => {
+        this.displayFacetValueModal().subscribe(facetValueIds => {
             if (facetValueIds) {
                 const productGroup = this.getProductFormGroup();
                 const currentFacetValueIds = productGroup.value.facetValueIds;
@@ -263,7 +284,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     entity: 'ProductOption',
                 });
             },
-            (err) => {
+            err => {
                 this.notificationService.error(_('common.notify-update-error'), {
                     entity: 'ProductOption',
                 });
@@ -275,7 +296,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         const productGroup = this.getProductFormGroup();
         const currentFacetValueIds = productGroup.value.facetValueIds;
         productGroup.patchValue({
-            facetValueIds: currentFacetValueIds.filter((id) => id !== facetValueId),
+            facetValueIds: currentFacetValueIds.filter(id => id !== facetValueId),
         });
         productGroup.markAsDirty();
     }
@@ -289,9 +310,9 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
             .subscribe(([facetValueIds, variants]) => {
                 if (facetValueIds) {
                     for (const variantId of selectedVariantIds) {
-                        const index = variants.findIndex((v) => v.id === variantId);
+                        const index = variants.findIndex(v => v.id === variantId);
                         const variant = variants[index];
-                        const existingFacetValueIds = variant ? variant.facetValues.map((fv) => fv.id) : [];
+                        const existingFacetValueIds = variant ? variant.facetValues.map(fv => fv.id) : [];
                         const variantFormGroup = this.detailForm.get(['variants', index]);
                         if (variantFormGroup) {
                             variantFormGroup.patchValue({
@@ -308,7 +329,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     variantsToCreateAreValid(): boolean {
         return (
             0 < this.createVariantsConfig.variants.length &&
-            this.createVariantsConfig.variants.every((v) => {
+            this.createVariantsConfig.variants.every(v => {
                 return v.sku !== '';
             })
         );
@@ -316,14 +337,14 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
 
     private displayFacetValueModal(): Observable<string[] | undefined> {
         return this.productDetailService.getFacets().pipe(
-            mergeMap((facets) =>
+            mergeMap(facets =>
                 this.modalService.fromComponent(ApplyFacetDialogComponent, {
                     size: 'md',
                     closable: true,
                     locals: { facets },
                 }),
             ),
-            map((facetValues) => facetValues && facetValues.map((v) => v.id)),
+            map(facetValues => facetValues && facetValues.map(v => v.id)),
         );
     }
 
@@ -358,7 +379,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     this.detailForm.markAsPristine();
                     this.router.navigate(['../', productId], { relativeTo: this.route });
                 },
-                (err) => {
+                err => {
                     // tslint:disable-next-line:no-console
                     console.error(err);
                     this.notificationService.error(_('common.notify-create-error'), {
@@ -397,7 +418,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                 }),
             )
             .subscribe(
-                (result) => {
+                result => {
                     this.updateSlugAfterSave(result);
                     this.detailForm.markAsPristine();
                     this.assetChanges = {};
@@ -407,7 +428,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     });
                     this.changeDetector.markForCheck();
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Product',
                     });
@@ -423,14 +444,14 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
      * Sets the values of the form on changes to the product or current language.
      */
     protected setFormValues(product: ProductWithVariants.Fragment, languageCode: LanguageCode) {
-        const currentTranslation = product.translations.find((t) => t.languageCode === languageCode);
+        const currentTranslation = product.translations.find(t => t.languageCode === languageCode);
         this.detailForm.patchValue({
             product: {
                 enabled: product.enabled,
                 name: currentTranslation ? currentTranslation.name : '',
                 slug: currentTranslation ? currentTranslation.slug : '',
                 description: currentTranslation ? currentTranslation.description : '',
-                facetValueIds: product.facetValues.map((fv) => fv.id),
+                facetValueIds: product.facetValues.map(fv => fv.id),
             },
         });
 
@@ -452,9 +473,10 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
 
         const variantsFormArray = this.detailForm.get('variants') as FormArray;
         product.variants.forEach((variant, i) => {
-            const variantTranslation = variant.translations.find((t) => t.languageCode === languageCode);
-            const facetValueIds = variant.facetValues.map((fv) => fv.id);
+            const variantTranslation = variant.translations.find(t => t.languageCode === languageCode);
+            const facetValueIds = variant.facetValues.map(fv => fv.id);
             const group: VariantFormValue = {
+                id: variant.id,
                 enabled: variant.enabled,
                 sku: variant.sku,
                 name: variantTranslation ? variantTranslation.name : '',
@@ -543,7 +565,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
             const formRow = variantsFormArray.get(i.toString());
             return formRow && formRow.dirty;
         });
-        const dirtyVariantValues = variantsFormArray.controls.filter((c) => c.dirty).map((c) => c.value);
+        const dirtyVariantValues = variantsFormArray.controls.filter(c => c.dirty).map(c => c.value);
 
         if (dirtyVariants.length !== dirtyVariantValues.length) {
             throw new Error(_(`error.product-variant-form-values-do-not-match`));

+ 27 - 15
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html

@@ -1,10 +1,10 @@
 <div class="variants-list">
     <div
         class="variant-container card"
-        *ngFor="let variant of variants; let i = index"
-        [class.disabled]="!formArray.get([i, 'enabled'])?.value"
+        *ngFor="let variant of variants | paginate: pagination; trackBy:trackById, let i = index"
+        [class.disabled]="!formGroupMap.get(variant.id)?.get('enabled')?.value"
     >
-        <ng-container *ngIf="formArray.at(i)" [formGroup]="formArray.at(i)">
+        <ng-container *ngIf="formGroupMap.get(variant.id) as formGroup" [formGroup]="formGroup">
             <div class="card-block header-row">
                 <div class="details">
                     <vdr-title-input class="sku" [readonly]="!('UpdateCatalog' | hasPermission)">
@@ -51,7 +51,9 @@
                         <div class="standard-fields">
                             <div class="variant-form-input-row">
                                 <div class="tax-category">
-                                    <clr-select-container *vdrIfPermissions="'UpdateCatalog'; else taxCategoryLabel">
+                                    <clr-select-container
+                                        *vdrIfPermissions="'UpdateCatalog'; else taxCategoryLabel"
+                                    >
                                         <label>{{ 'catalog.tax-category' | translate }}</label>
                                         <select clrSelect name="options" formControlName="taxCategoryId">
                                             <option
@@ -63,9 +65,11 @@
                                         </select>
                                     </clr-select-container>
                                     <ng-template #taxCategoryLabel>
-                                        <label class="clr-control-label">{{ 'catalog.tax-category' | translate }}</label>
+                                        <label class="clr-control-label">{{
+                                            'catalog.tax-category' | translate
+                                        }}</label>
                                         <div class="tax-category-label">
-                                            {{ getTaxCategoryName(i) }}
+                                            {{ getTaxCategoryName(formGroup) }}
                                         </div>
                                     </ng-template>
                                 </div>
@@ -81,10 +85,10 @@
                                     </clr-input-container>
                                 </div>
                                 <vdr-variant-price-detail
-                                    [price]="formArray.get([i, 'price'])!.value"
+                                    [price]="formGroup.get('price')!.value"
                                     [currencyCode]="variant.currencyCode"
                                     [priceIncludesTax]="variant.priceIncludesTax"
-                                    [taxCategoryId]="formArray.get([i, 'taxCategoryId'])!.value"
+                                    [taxCategoryId]="formGroup.get('taxCategoryId')!.value"
                                 ></vdr-variant-price-detail>
                             </div>
                             <div class="variant-form-input-row">
@@ -104,7 +108,6 @@
                                         type="checkbox"
                                         clrCheckbox
                                         name="trackInventory"
-
                                         formControlName="trackInventory"
                                         [vdrDisabled]="!('UpdateCatalog' | hasPermission)"
                                     />
@@ -118,10 +121,10 @@
                                     <!--<label>{{ 'common.custom-fields' | translate }}</label>-->
                                     <ng-container *ngFor="let customField of customFields">
                                         <vdr-custom-field-control
-                                            *ngIf="customFieldIsSet(i, customField.name)"
+                                            *ngIf="formGroup.get(['customFields', customField.name])"
                                             entityName="ProductVariant"
                                             [compact]="true"
-                                            [customFieldsFormGroup]="formArray.at(i).get(['customFields'])"
+                                            [customFieldsFormGroup]="formGroup.get('customFields')"
                                             [readonly]="!('UpdateCatalog' | hasPermission)"
                                             [customField]="customField"
                                         ></vdr-custom-field-control>
@@ -152,16 +155,16 @@
                     <div class="flex-spacer"></div>
                     <div class="facets">
                         <vdr-facet-value-chip
-                            *ngFor="let facetValue of existingFacetValues(i)"
+                            *ngFor="let facetValue of existingFacetValues(variant)"
                             [facetValue]="facetValue"
                             [removable]="'UpdateCatalog' | hasPermission"
-                            (remove)="removeFacetValue(i, facetValue.id)"
+                            (remove)="removeFacetValue(variant, facetValue.id)"
                         ></vdr-facet-value-chip>
                         <vdr-facet-value-chip
-                            *ngFor="let facetValue of pendingFacetValues(i)"
+                            *ngFor="let facetValue of pendingFacetValues(variant)"
                             [facetValue]="facetValue"
                             [removable]="'UpdateCatalog' | hasPermission"
-                            (remove)="removeFacetValue(i, facetValue.id)"
+                            (remove)="removeFacetValue(variant, facetValue.id)"
                         ></vdr-facet-value-chip>
                         <button
                             *vdrIfPermissions="'UpdateCatalog'"
@@ -176,4 +179,13 @@
             </div>
         </ng-container>
     </div>
+    <div class="table-footer">
+        <vdr-items-per-page-controls [(itemsPerPage)]="pagination.itemsPerPage"></vdr-items-per-page-controls>
+
+        <vdr-pagination-controls
+            [currentPage]="pagination.currentPage"
+            [itemsPerPage]="pagination.itemsPerPage"
+            (pageChange)="pagination.currentPage = $event"
+        ></vdr-pagination-controls>
+    </div>
 </div>

+ 7 - 0
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss

@@ -19,6 +19,7 @@
 
 .variant-container {
     transition: background-color 0.2s;
+    min-height: 330px;
     &.disabled {
         background-color: $color-grey-200;
     }
@@ -129,3 +130,9 @@
         color: $color-grey-400;
     }
 }
+.table-footer {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    margin-top: 6px;
+}

+ 76 - 43
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts

@@ -10,22 +10,24 @@ import {
     Output,
     SimpleChanges,
 } from '@angular/core';
-import { FormArray } from '@angular/forms';
+import { FormArray, FormGroup } from '@angular/forms';
 import {
     CustomFieldConfig,
     FacetValue,
     FacetWithValues,
+    flattenFacetValues,
     LanguageCode,
+    ModalService,
     ProductOptionFragment,
     ProductVariant,
     ProductWithVariants,
     TaxCategory,
     UpdateProductOptionInput,
 } from '@vendure/admin-ui/core';
-import { flattenFacetValues } from '@vendure/admin-ui/core';
-import { ModalService } from '@vendure/admin-ui/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { PaginationInstance } from 'ngx-pagination';
 import { Subscription } from 'rxjs';
+import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
 import { AssetChange } from '../product-assets/product-assets.component';
 import { VariantFormValue } from '../product-detail/product-detail.component';
@@ -55,33 +57,59 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     @Output() selectFacetValueClick = new EventEmitter<string[]>();
     @Output() updateProductOption = new EventEmitter<UpdateProductOptionInput>();
     selectedVariantIds: string[] = [];
+    pagination: PaginationInstance = {
+        currentPage: 1,
+        itemsPerPage: 10,
+    };
+    formGroupMap = new Map<string, FormGroup>();
     private facetValues: FacetValue.Fragment[];
-    private formSubscription: Subscription;
+    private subscription: Subscription;
 
     constructor(private changeDetector: ChangeDetectorRef, private modalService: ModalService) {}
 
     ngOnInit() {
-        this.formSubscription = this.formArray.valueChanges.subscribe(() =>
-            this.changeDetector.markForCheck(),
+        this.subscription = this.formArray.valueChanges.subscribe(() => this.changeDetector.markForCheck());
+
+        this.subscription.add(
+            this.formArray.valueChanges
+                .pipe(
+                    map(value => value.length),
+                    debounceTime(1),
+                    distinctUntilChanged(),
+                )
+                .subscribe(() => {
+                    this.buildFormGroupMap();
+                }),
         );
+
+        this.buildFormGroupMap();
     }
 
     ngOnChanges(changes: SimpleChanges) {
         if ('facets' in changes && !!changes['facets'].currentValue) {
             this.facetValues = flattenFacetValues(this.facets);
         }
+        if ('variants' in changes) {
+            if (changes['variants'].currentValue?.length !== changes['variants'].previousValue?.length) {
+                this.pagination.currentPage = 1;
+            }
+        }
     }
 
     ngOnDestroy() {
-        if (this.formSubscription) {
-            this.formSubscription.unsubscribe();
+        if (this.subscription) {
+            this.subscription.unsubscribe();
         }
     }
 
-    getTaxCategoryName(index: number): string {
-        const control = this.formArray.at(index).get(['taxCategoryId']);
+    trackById(index: number, item: ProductWithVariants.Variants) {
+        return item.id;
+    }
+
+    getTaxCategoryName(group: FormGroup): string {
+        const control = group.get(['taxCategoryId']);
         if (control && this.taxCategories) {
-            const match = this.taxCategories.find((t) => t.id === control.value);
+            const match = this.taxCategories.find(t => t.id === control.value);
             return match ? match.name : '';
         }
         return '';
@@ -96,7 +124,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
             variantId,
             ...event,
         });
-        const index = this.variants.findIndex((v) => v.id === variantId);
+        const index = this.variants.findIndex(v => v.id === variantId);
         this.formArray.at(index).markAsDirty();
     }
 
@@ -104,7 +132,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         if (this.areAllSelected()) {
             this.selectedVariantIds = [];
         } else {
-            this.selectedVariantIds = this.variants.map((v) => v.id);
+            this.selectedVariantIds = this.variants.map(v => v.id);
         }
         this.selectionChange.emit(this.selectedVariantIds);
     }
@@ -120,10 +148,10 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     }
 
     optionGroupName(optionGroupId: string): string | undefined {
-        const group = this.optionGroups.find((g) => g.id === optionGroupId);
+        const group = this.optionGroups.find(g => g.id === optionGroupId);
         if (group) {
             const translation =
-                group?.translations.find((t) => t.languageCode === this.activeLanguage) ??
+                group?.translations.find(t => t.languageCode === this.activeLanguage) ??
                 group.translations[0];
             return translation.name;
         }
@@ -131,53 +159,50 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
 
     optionName(option: ProductOptionFragment) {
         const translation =
-            option.translations.find((t) => t.languageCode === this.activeLanguage) ?? option.translations[0];
+            option.translations.find(t => t.languageCode === this.activeLanguage) ?? option.translations[0];
         return translation.name;
     }
 
-    pendingFacetValues(index: number) {
+    pendingFacetValues(variant: ProductWithVariants.Variants) {
         if (this.facets) {
-            const formFacetValueIds = this.getFacetValueIds(index);
-            const variantFacetValueIds = this.variants[index].facetValues.map((fv) => fv.id);
+            const formFacetValueIds = this.getFacetValueIds(variant.id);
+            const variantFacetValueIds = variant.facetValues.map(fv => fv.id);
             return formFacetValueIds
-                .filter((x) => !variantFacetValueIds.includes(x))
-                .map((id) => this.facetValues.find((fv) => fv.id === id))
+                .filter(x => !variantFacetValueIds.includes(x))
+                .map(id => this.facetValues.find(fv => fv.id === id))
                 .filter(notNullOrUndefined);
         } else {
             return [];
         }
     }
 
-    existingFacetValues(index: number) {
-        const variant = this.variants[index];
-        const formFacetValueIds = this.getFacetValueIds(index);
-        const intersection = [...formFacetValueIds].filter((x) =>
-            variant.facetValues.map((fv) => fv.id).includes(x),
+    existingFacetValues(variant: ProductWithVariants.Variants) {
+        const formFacetValueIds = this.getFacetValueIds(variant.id);
+        const intersection = [...formFacetValueIds].filter(x =>
+            variant.facetValues.map(fv => fv.id).includes(x),
         );
         return intersection
-            .map((id) => variant.facetValues.find((fv) => fv.id === id))
+            .map(id => variant.facetValues.find(fv => fv.id === id))
             .filter(notNullOrUndefined);
     }
 
-    removeFacetValue(index: number, facetValueId: string) {
-        const formGroup = this.formArray.at(index);
-        const newValue = (formGroup.value as VariantFormValue).facetValueIds.filter(
-            (id) => id !== facetValueId,
-        );
-        formGroup.patchValue({
-            facetValueIds: newValue,
-        });
-        formGroup.markAsDirty();
+    removeFacetValue(variant: ProductWithVariants.Variants, facetValueId: string) {
+        const formGroup = this.formGroupMap.get(variant.id);
+        if (formGroup) {
+            const newValue = (formGroup.value as VariantFormValue).facetValueIds.filter(
+                id => id !== facetValueId,
+            );
+            formGroup.patchValue({
+                facetValueIds: newValue,
+            });
+            formGroup.markAsDirty();
+        }
     }
 
     isVariantSelected(variantId: string): boolean {
         return -1 < this.selectedVariantIds.indexOf(variantId);
     }
 
-    customFieldIsSet(index: number, name: string): boolean {
-        return !!this.formArray.at(index).get(['customFields', name]);
-    }
-
     editOption(option: ProductVariant.Options) {
         this.modalService
             .fromComponent(UpdateProductOptionDialogComponent, {
@@ -188,15 +213,23 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
                     customFields: this.customOptionFields,
                 },
             })
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.updateProductOption.emit(result);
                 }
             });
     }
 
-    private getFacetValueIds(index: number): string[] {
-        const formValue: VariantFormValue = this.formArray.at(index).value;
+    private buildFormGroupMap() {
+        this.formGroupMap.clear();
+        for (const controlGroup of this.formArray.controls) {
+            this.formGroupMap.set(controlGroup.value.id, controlGroup as FormGroup);
+        }
+        this.changeDetector.markForCheck();
+    }
+
+    private getFacetValueIds(id: string): string[] {
+        const formValue: VariantFormValue = this.formGroupMap.get(id)?.value;
         return formValue.facetValueIds;
     }
 }

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -86,6 +86,7 @@
     "expand-all-collections": "Alle Sammlungen erweitern",
     "facet-values": "Facettenwerte",
     "filter-by-name": "Nach Name filtern",
+    "filter-by-name-or-sku": "Nach Name oder Artikelnummer filtern",
     "filters": "Filter",
     "group-by-product": "Nach Produkt gruppieren",
     "manage-variants": "Varianten verwalten",

+ 2 - 1
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -86,6 +86,7 @@
     "expand-all-collections": "Expand all collections",
     "facet-values": "Facet values",
     "filter-by-name": "Filter by name",
+    "filter-by-name-or-sku": "Filter by name or SKU",
     "filters": "Filters",
     "group-by-product": "Group by product",
     "manage-variants": "Manage variants",
@@ -690,4 +691,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -86,6 +86,7 @@
     "expand-all-collections": "Expandir todas las colecciones",
     "facet-values": "Valores de faceta",
     "filter-by-name": "Filtrar por nombre",
+    "filter-by-name-or-sku": "",
     "filters": "Filtros",
     "group-by-product": "Agrupar por producto",
     "manage-variants": "Gestionar variantes",

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -86,6 +86,7 @@
     "expand-all-collections": "Rozwiń wszystkie kolekcje",
     "facet-values": "Wartości faseta",
     "filter-by-name": "Filtruj po nazwie",
+    "filter-by-name-or-sku": "",
     "filters": "Filtry",
     "group-by-product": "Grupuj po produkcie",
     "manage-variants": "Zarządzaj wariantami",

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -86,6 +86,7 @@
     "expand-all-collections": "展开所有系列",
     "facet-values": "特征值列表",
     "filter-by-name": "按名字过滤",
+    "filter-by-name-or-sku": "",
     "filters": "过滤条件",
     "group-by-product": "按商品分组显示",
     "manage-variants": "商品规格管理",

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -86,6 +86,7 @@
     "expand-all-collections": "展開所有系列",
     "facet-values": "特徵值列表",
     "filter-by-name": "按名字篩選",
+    "filter-by-name-or-sku": "",
     "filters": "篩選條件",
     "group-by-product": "按商品分组顯示",
     "manage-variants": "商品規格管理",