Browse Source

feat(admin-ui): Use server pagination of product variants

Relates to #1110
Michael Bromley 4 years ago
parent
commit
552eafea56
19 changed files with 544 additions and 325 deletions
  1. 2 2
      packages/admin-ui/src/lib/catalog/src/catalog.routes.ts
  2. 2 2
      packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts
  3. 31 6
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  4. 6 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  5. 106 37
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  6. 1 10
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html
  7. 0 6
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss
  8. 16 20
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  9. 105 99
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.html
  10. 14 5
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts
  11. 100 77
      packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts
  12. 1 4
      packages/admin-ui/src/lib/catalog/src/providers/routing/collection-resolver.ts
  13. 11 9
      packages/admin-ui/src/lib/catalog/src/providers/routing/product-resolver.ts
  14. 84 24
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  15. 47 18
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  16. 13 3
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  17. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/pagination-controls/pagination-controls.component.html
  18. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/pagination-controls/pagination-controls.component.ts
  19. 3 2
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.ts

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

@@ -7,7 +7,7 @@ import {
     createResolveData,
     detailBreadcrumb,
     FacetWithValues,
-    ProductWithVariants,
+    GetProductWithVariants,
 } from '@vendure/admin-ui/core';
 import { map } from 'rxjs/operators';
 
@@ -112,7 +112,7 @@ export const catalogRoutes: Route[] = [
 ];
 
 export function productBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<ProductWithVariants.Fragment>({
+    return detailBreadcrumb<GetProductWithVariants.Product>({
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.products',

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts

@@ -111,9 +111,9 @@ export class AssignProductsToChannelDialogComponent implements OnInit, Dialog<an
 
         for (let i = 0; i < this.productIds.length && variants.length < take; i++) {
             const productVariants = await this.dataService.product
-                .getProduct(this.productIds[i])
+                .getProduct(this.productIds[i], { take: this.isProductVariantMode ? undefined : take })
                 .mapSingle(({ product }) => {
-                    const _variants = product ? product.variants : [];
+                    const _variants = product ? product.variantList.items : [];
                     return _variants.filter(v =>
                         this.isProductVariantMode ? this.productVariantIds?.includes(v.id) : true,
                     );

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

@@ -45,11 +45,7 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<form
-    class="form"
-    [formGroup]="detailForm"
-    *ngIf="product$ | async as product"
->
+<form class="form" [formGroup]="detailForm" *ngIf="product$ | async as product">
     <button type="submit" hidden x-data="prevents enter key from triggering other buttons"></button>
     <clr-tabs>
         <clr-tab>
@@ -111,7 +107,7 @@
                                     />
                                     <label>{{
                                         'catalog.auto-update-product-variant-name' | translate
-                                        }}</label>
+                                    }}</label>
                                 </clr-checkbox-wrapper>
                             </div>
                             <vdr-form-field
@@ -225,9 +221,24 @@
                         </a>
                     </div>
 
+                    <div class="pagination-row mt4" *ngIf="10 < (paginationConfig$ | async)?.totalItems">
+                        <vdr-items-per-page-controls
+                            [itemsPerPage]="itemsPerPage$ | async"
+                            (itemsPerPageChange)="setItemsPerPage($event)"
+                        ></vdr-items-per-page-controls>
+
+                        <vdr-pagination-controls
+                            [id]="(paginationConfig$ | async)?.id"
+                            [currentPage]="currentPage$ | async"
+                            [itemsPerPage]="itemsPerPage$ | async"
+                            (pageChange)="setPage($event)"
+                        ></vdr-pagination-controls>
+                    </div>
+
                     <vdr-product-variants-table
                         *ngIf="variantDisplayMode === 'table'"
                         [variants]="variants$ | async"
+                        [paginationConfig]="paginationConfig$ | async"
                         [optionGroups]="product.optionGroups"
                         [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
                         [productVariantsFormArray]="detailForm.get('variants')"
@@ -236,6 +247,7 @@
                     <vdr-product-variants-list
                         *ngIf="variantDisplayMode === 'card'"
                         [variants]="variants$ | async"
+                        [paginationConfig]="paginationConfig$ | async"
                         [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
                         [facets]="facets$ | async"
                         [optionGroups]="product.optionGroups"
@@ -253,6 +265,19 @@
                         (selectFacetValueClick)="selectVariantFacetValue($event)"
                     ></vdr-product-variants-list>
                 </section>
+                <div class="pagination-row mt4" *ngIf="10 < (paginationConfig$ | async)?.totalItems">
+                    <vdr-items-per-page-controls
+                        [itemsPerPage]="itemsPerPage$ | async"
+                        (itemsPerPageChange)="setItemsPerPage($event)"
+                    ></vdr-items-per-page-controls>
+
+                    <vdr-pagination-controls
+                        [id]="(paginationConfig$ | async)?.id"
+                        [currentPage]="currentPage$ | async"
+                        [itemsPerPage]="itemsPerPage$ | async"
+                        (pageChange)="setPage($event)"
+                    ></vdr-pagination-controls>
+                </div>
             </clr-tab-content>
         </clr-tab>
     </clr-tabs>

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

@@ -56,3 +56,9 @@ vdr-action-bar clr-toggle-wrapper {
         margin-bottom: 12px;
     }
 }
+
+.pagination-row {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+}

+ 106 - 37
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -13,11 +13,13 @@ import {
     FacetWithValues,
     findTranslation,
     flattenFacetValues,
+    GetProductWithVariants,
     GlobalFlag,
     LanguageCode,
     ModalService,
     NotificationService,
-    ProductWithVariants,
+    ProductDetail,
+    ProductVariant,
     ServerConfigService,
     TaxCategory,
     unicodePatternValidator,
@@ -31,17 +33,20 @@ 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 { BehaviorSubject, combineLatest, EMPTY, merge, Observable } from 'rxjs';
 import {
     debounceTime,
     distinctUntilChanged,
     map,
     mergeMap,
     shareReplay,
+    skip,
+    skipUntil,
     startWith,
     switchMap,
     take,
     takeUntil,
+    tap,
     withLatestFrom,
 } from 'rxjs/operators';
 
@@ -52,6 +57,7 @@ import { CreateProductVariantsConfig } from '../generate-product-variants/genera
 import { VariantAssetChange } from '../product-variants-list/product-variants-list.component';
 
 export type TabName = 'details' | 'variants';
+
 export interface VariantFormValue {
     id: string;
     enabled: boolean;
@@ -73,6 +79,12 @@ export interface SelectedAssets {
     featuredAsset?: Asset;
 }
 
+export interface PaginationConfig {
+    totalItems: number;
+    currentPage: number;
+    itemsPerPage: number;
+}
+
 @Component({
     selector: 'vdr-product-detail',
     templateUrl: './product-detail.component.html',
@@ -80,12 +92,12 @@ export interface SelectedAssets {
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ProductDetailComponent
-    extends BaseDetailComponent<ProductWithVariants.Fragment>
+    extends BaseDetailComponent<GetProductWithVariants.Product>
     implements OnInit, OnDestroy
 {
     activeTab$: Observable<TabName>;
-    product$: Observable<ProductWithVariants.Fragment>;
-    variants$: Observable<ProductWithVariants.Variants[]>;
+    product$: Observable<GetProductWithVariants.Product>;
+    variants$: Observable<ProductVariant.Fragment[]>;
     taxCategories$: Observable<TaxCategory.Fragment[]>;
     customFields: CustomFieldConfig[];
     customVariantFields: CustomFieldConfig[];
@@ -95,13 +107,21 @@ export class ProductDetailComponent
     filterInput = new FormControl('');
     assetChanges: SelectedAssets = {};
     variantAssetChanges: { [variantId: string]: SelectedAssets } = {};
-    productChannels$: Observable<ProductWithVariants.Channels[]>;
-    facetValues$: Observable<ProductWithVariants.FacetValues[]>;
+    variantFacetValueChanges: { [variantId: string]: string[] } = {};
+    productChannels$: Observable<ProductDetail.Channels[]>;
+    facetValues$: Observable<ProductDetail.FacetValues[]>;
     facets$: Observable<FacetWithValues.Fragment[]>;
+    totalItems$: Observable<number>;
+    currentPage$ = new BehaviorSubject(1);
+    itemsPerPage$ = new BehaviorSubject(10);
+    paginationConfig$: Observable<PaginationConfig>;
     selectedVariantIds: string[] = [];
     variantDisplayMode: 'card' | 'table' = 'card';
     createVariantsConfig: CreateProductVariantsConfig = { groups: [], variants: [] };
     channelPriceIncludesTax$: Observable<boolean>;
+    // Used to store all ProductVariants which have been loaded.
+    // It is needed when saving changes to variants.
+    private productVariantMap = new Map<string, ProductVariant.Fragment>();
 
     constructor(
         route: ActivatedRoute,
@@ -139,28 +159,54 @@ export class ProductDetailComponent
     ngOnInit() {
         this.init();
         this.product$ = this.entity$;
-        const variants$ = this.product$.pipe(map(product => product.variants));
+        this.totalItems$ = this.product$.pipe(map(product => product.variantList.totalItems));
+        this.paginationConfig$ = combineLatest(this.totalItems$, this.itemsPerPage$, this.currentPage$).pipe(
+            map(([totalItems, itemsPerPage, currentPage]) => ({
+                totalItems,
+                itemsPerPage,
+                currentPage,
+            })),
+        );
+        const variants$ = this.product$.pipe(map(product => product.variantList.items));
         const filterTerm$ = this.filterInput.valueChanges.pipe(
             startWith(''),
-            debounceTime(50),
+            debounceTime(200),
             shareReplay(),
         );
-        this.variants$ = combineLatest(variants$, filterTerm$).pipe(
-            map(([variants, term]) => {
-                return term
-                    ? variants.filter(v => {
-                          const lcTerm = term.toLocaleLowerCase();
-                          return (
-                              v.name.toLocaleLowerCase().includes(lcTerm) ||
-                              v.sku.toLocaleLowerCase().includes(lcTerm)
-                          );
-                      })
-                    : variants;
+        const initialVariants$ = this.product$.pipe(map(p => p.variantList.items));
+        const updatedVariants$ = combineLatest(filterTerm$, this.currentPage$, this.itemsPerPage$).pipe(
+            skipUntil(initialVariants$),
+            skip(1),
+            switchMap(([term, currentPage, itemsPerPage]) => {
+                return this.dataService.product
+                    .getProductVariants(
+                        {
+                            skip: (currentPage - 1) * itemsPerPage,
+                            take: itemsPerPage,
+                            ...(term ? { filter: { name: { contains: term } } } : {}),
+                        },
+                        this.id,
+                    )
+                    .mapStream(({ productVariants }) => productVariants.items);
+            }),
+            shareReplay({ bufferSize: 1, refCount: true }),
+        );
+        this.variants$ = merge(initialVariants$, updatedVariants$).pipe(
+            tap(variants => {
+                for (const variant of variants) {
+                    this.productVariantMap.set(variant.id, variant);
+                }
             }),
         );
         this.taxCategories$ = this.productDetailService.getTaxCategories().pipe(takeUntil(this.destroy$));
         this.activeTab$ = this.route.paramMap.pipe(map(qpm => qpm.get('tab') as any));
 
+        combineLatest(updatedVariants$, this.languageCode$)
+            .pipe(takeUntil(this.destroy$))
+            .subscribe(([variants, languageCode]) => {
+                this.buildVariantFormArray(variants, languageCode);
+            });
+
         // 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
@@ -213,6 +259,15 @@ export class ProductDetailComponent
         return channelCode === DEFAULT_CHANNEL_CODE;
     }
 
+    setPage(page: number) {
+        this.currentPage$.next(page);
+    }
+
+    setItemsPerPage(value: string) {
+        this.itemsPerPage$.next(+value);
+        this.currentPage$.next(1);
+    }
+
     assignToChannel() {
         this.productChannels$
             .pipe(
@@ -259,7 +314,7 @@ export class ProductDetailComponent
             );
     }
 
-    assignVariantToChannel(variant: ProductWithVariants.Variants) {
+    assignVariantToChannel(variant: ProductVariant.Fragment) {
         return this.modalService
             .fromComponent(AssignProductsToChannelDialogComponent, {
                 size: 'lg',
@@ -277,7 +332,7 @@ export class ProductDetailComponent
         variant,
     }: {
         channelId: string;
-        variant: ProductWithVariants.Variants;
+        variant: ProductVariant.Fragment;
     }) {
         this.modalService
             .dialog({
@@ -395,12 +450,16 @@ export class ProductDetailComponent
                         const index = variants.findIndex(v => v.id === variantId);
                         const variant = variants[index];
                         const existingFacetValueIds = variant ? variant.facetValues.map(fv => fv.id) : [];
-                        const variantFormGroup = this.detailForm.get(['variants', index]);
+                        const variantFormGroup = (this.detailForm.get('variants') as FormArray).controls.find(
+                            c => c.value.id === variantId,
+                        );
                         if (variantFormGroup) {
+                            const uniqueFacetValueIds = unique([...existingFacetValueIds, ...facetValueIds]);
                             variantFormGroup.patchValue({
-                                facetValueIds: unique([...existingFacetValueIds, ...facetValueIds]),
+                                facetValueIds: uniqueFacetValueIds,
                             });
                             variantFormGroup.markAsDirty();
+                            this.variantFacetValueChanges[variantId] = uniqueFacetValueIds;
                         }
                     }
                     this.changeDetector.markForCheck();
@@ -533,7 +592,7 @@ export class ProductDetailComponent
     /**
      * Sets the values of the form on changes to the product or current language.
      */
-    protected setFormValues(product: ProductWithVariants.Fragment, languageCode: LanguageCode) {
+    protected setFormValues(product: GetProductWithVariants.Product, languageCode: LanguageCode) {
         const currentTranslation = findTranslation(product, languageCode);
         this.detailForm.patchValue({
             product: {
@@ -560,11 +619,17 @@ export class ProductDetailComponent
                 }
             }
         }
+        this.buildVariantFormArray(product.variantList.items, languageCode);
+    }
 
+    private buildVariantFormArray(variants: ProductVariant.Fragment[], languageCode: LanguageCode) {
         const variantsFormArray = this.detailForm.get('variants') as FormArray;
-        product.variants.forEach((variant, i) => {
+        variants.forEach((variant, i) => {
             const variantTranslation = findTranslation(variant, languageCode);
-            const facetValueIds = variant.facetValues.map(fv => fv.id);
+            const pendingFacetValueChanges = this.variantFacetValueChanges[variant.id];
+            const facetValueIds = pendingFacetValueChanges
+                ? pendingFacetValueChanges
+                : variant.facetValues.map(fv => fv.id);
             const group: VariantFormValue = {
                 id: variant.id,
                 enabled: variant.enabled,
@@ -580,9 +645,13 @@ export class ProductDetailComponent
                 facetValueIds,
             };
 
-            let variantFormGroup = variantsFormArray.at(i) as FormGroup | undefined;
+            let variantFormGroup = variantsFormArray.controls.find(c => c.value.id === variant.id) as
+                | FormGroup
+                | undefined;
             if (variantFormGroup) {
-                variantFormGroup.patchValue(group);
+                if (variantFormGroup.pristine) {
+                    variantFormGroup.patchValue(group);
+                }
             } else {
                 variantFormGroup = this.formBuilder.group({
                     ...group,
@@ -620,7 +689,7 @@ export class ProductDetailComponent
      * can then be persisted to the API.
      */
     private getUpdatedProduct(
-        product: ProductWithVariants.Fragment,
+        product: GetProductWithVariants.Product,
         productFormGroup: FormGroup,
         languageCode: LanguageCode,
     ): UpdateProductInput | CreateProductInput {
@@ -649,23 +718,23 @@ export class ProductDetailComponent
      * which can be persisted to the API.
      */
     private getUpdatedProductVariants(
-        product: ProductWithVariants.Fragment,
+        product: GetProductWithVariants.Product,
         variantsFormArray: FormArray,
         languageCode: LanguageCode,
         priceIncludesTax: boolean,
     ): UpdateProductVariantInput[] {
-        const dirtyVariants = product.variants.filter((v, i) => {
-            const formRow = variantsFormArray.get(i.toString());
-            return formRow && formRow.dirty;
-        });
-        const dirtyVariantValues = variantsFormArray.controls.filter(c => c.dirty).map(c => c.value);
+        const dirtyFormControls = variantsFormArray.controls.filter(c => c.dirty);
+        const dirtyVariants = dirtyFormControls
+            .map(c => this.productVariantMap.get(c.value.id))
+            .filter(notNullOrUndefined);
+        const dirtyVariantValues = dirtyFormControls.map(c => c.value);
 
         if (dirtyVariants.length !== dirtyVariantValues.length) {
             throw new Error(_(`error.product-variant-form-values-do-not-match`));
         }
         return dirtyVariants
             .map((variant, i) => {
-                const formValue: VariantFormValue = dirtyVariantValues[i];
+                const formValue: VariantFormValue = dirtyVariantValues.find(value => value.id === variant.id);
                 const result: UpdateProductVariantInput = createUpdatedTranslatable({
                     translatable: variant,
                     updatedFields: formValue,

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

@@ -1,7 +1,7 @@
 <div class="variants-list">
     <div
         class="variant-container card"
-        *ngFor="let variant of variants | paginate: pagination; trackBy: trackById; let i = index"
+        *ngFor="let variant of variants | paginate: paginationConfig || { itemsPerPage: 10, currentPage: 1 }; trackBy: trackById; let i = index"
         [class.disabled]="!formGroupMap.get(variant.id)?.get('enabled')?.value"
     >
         <ng-container *ngIf="formGroupMap.get(variant.id) as formGroup" [formGroup]="formGroup">
@@ -294,13 +294,4 @@
             </ng-container>
         </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>

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

@@ -154,9 +154,3 @@
     opacity: 0.5;
 }
 
-.table-footer {
-    display: flex;
-    align-items: baseline;
-    justify-content: space-between;
-    margin-top: 6px;
-}

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

@@ -21,9 +21,9 @@ import {
     LanguageCode,
     ModalService,
     Permission,
+    ProductDetail,
     ProductOptionFragment,
     ProductVariant,
-    ProductWithVariants,
     TaxCategory,
     UpdateProductOptionInput,
 } from '@vendure/admin-ui/core';
@@ -34,7 +34,11 @@ import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
 import { AssetChange } from '../product-assets/product-assets.component';
-import { SelectedAssets, VariantFormValue } from '../product-detail/product-detail.component';
+import {
+    PaginationConfig,
+    SelectedAssets,
+    VariantFormValue,
+} from '../product-detail/product-detail.component';
 import { UpdateProductOptionDialogComponent } from '../update-product-option-dialog/update-product-option-dialog.component';
 
 export interface VariantAssetChange extends AssetChange {
@@ -49,29 +53,26 @@ export interface VariantAssetChange extends AssetChange {
 })
 export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestroy {
     @Input('productVariantsFormArray') formArray: FormArray;
-    @Input() variants: ProductWithVariants.Variants[];
+    @Input() variants: ProductVariant.Fragment[];
+    @Input() paginationConfig: PaginationConfig;
     @Input() channelPriceIncludesTax: boolean;
     @Input() taxCategories: TaxCategory[];
     @Input() facets: FacetWithValues.Fragment[];
-    @Input() optionGroups: ProductWithVariants.OptionGroups[];
+    @Input() optionGroups: ProductDetail.OptionGroups[];
     @Input() customFields: CustomFieldConfig[];
     @Input() customOptionFields: CustomFieldConfig[];
     @Input() activeLanguage: LanguageCode;
     @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
-    @Output() assignToChannel = new EventEmitter<ProductWithVariants.Variants>();
+    @Output() assignToChannel = new EventEmitter<ProductVariant.Fragment>();
     @Output() removeFromChannel = new EventEmitter<{
         channelId: string;
-        variant: ProductWithVariants.Variants;
+        variant: ProductVariant.Fragment;
     }>();
     @Output() assetChange = new EventEmitter<VariantAssetChange>();
     @Output() selectionChange = new EventEmitter<string[]>();
     @Output() selectFacetValueClick = new EventEmitter<string[]>();
     @Output() updateProductOption = new EventEmitter<UpdateProductOptionInput & { autoUpdate: boolean }>();
     selectedVariantIds: string[] = [];
-    pagination: PaginationInstance = {
-        currentPage: 1,
-        itemsPerPage: 10,
-    };
     formGroupMap = new Map<string, FormGroup>();
     GlobalFlag = GlobalFlag;
     globalTrackInventory: boolean;
@@ -113,11 +114,6 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         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() {
@@ -130,7 +126,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         return channelCode === DEFAULT_CHANNEL_CODE;
     }
 
-    trackById(index: number, item: ProductWithVariants.Variants) {
+    trackById(index: number, item: ProductVariant.Fragment) {
         return item.id;
     }
 
@@ -151,7 +147,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         return '';
     }
 
-    getSaleableStockLevel(variant: ProductWithVariants.Variants) {
+    getSaleableStockLevel(variant: ProductVariant.Fragment) {
         const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold
             ? this.globalOutOfStockThreshold
             : variant.outOfStockThreshold;
@@ -206,7 +202,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         return translation.name;
     }
 
-    pendingFacetValues(variant: ProductWithVariants.Variants) {
+    pendingFacetValues(variant: ProductVariant.Fragment) {
         if (this.facets) {
             const formFacetValueIds = this.getFacetValueIds(variant.id);
             const variantFacetValueIds = variant.facetValues.map(fv => fv.id);
@@ -219,7 +215,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         }
     }
 
-    existingFacetValues(variant: ProductWithVariants.Variants) {
+    existingFacetValues(variant: ProductVariant.Fragment) {
         const formFacetValueIds = this.getFacetValueIds(variant.id);
         const intersection = [...formFacetValueIds].filter(x =>
             variant.facetValues.map(fv => fv.id).includes(x),
@@ -229,7 +225,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
             .filter(notNullOrUndefined);
     }
 
-    removeFacetValue(variant: ProductWithVariants.Variants, facetValueId: string) {
+    removeFacetValue(variant: ProductVariant.Fragment, facetValueId: string) {
         const formGroup = this.formGroupMap.get(variant.id);
         if (formGroup) {
             const newValue = (formGroup.value as VariantFormValue).facetValueIds.filter(

+ 105 - 99
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.html

@@ -1,102 +1,108 @@
-<vdr-data-table [items]="variants">
-    <vdr-dt-column></vdr-dt-column>
-    <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'catalog.sku' | translate }}</vdr-dt-column>
-    <ng-container *ngFor="let optionGroup of optionGroups | sort: 'id'">
-        <vdr-dt-column>{{ optionGroup.name }}</vdr-dt-column>
-    </ng-container>
-    <vdr-dt-column>{{ 'catalog.price' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'catalog.stock-on-hand' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'common.enabled' | translate }}</vdr-dt-column>
-    <ng-template let-variant="item" let-i="index">
-        <ng-container *ngIf="formGroupMap.get(variant.id) as formGroup" [formGroup]="formGroup">
-            <td class="left align-middle" [class.disabled]="!formGroup.get('enabled')!.value">
-                <div class="card-img">
-                    <div class="featured-asset">
-                        <img
-                            *ngIf="getFeaturedAsset(variant) as featuredAsset; else placeholder"
-                            [src]="featuredAsset | assetPreview: 'tiny'"
-                        />
-                        <ng-template #placeholder>
-                            <div class="placeholder">
-                                <clr-icon shape="image" size="48"></clr-icon>
-                            </div>
-                        </ng-template>
+<table class="table">
+    <thead>
+        <tr>
+            <th></th>
+            <th>{{ 'common.name' | translate }}</th>
+            <th>{{ 'catalog.sku' | translate }}</th>
+            <ng-container *ngFor="let optionGroup of optionGroups | sort: 'id'">
+                <th>{{ optionGroup.name }}</th>
+            </ng-container>
+            <th>{{ 'catalog.price' | translate }}</th>
+            <th>{{ 'catalog.stock-on-hand' | translate }}</th>
+            <th>{{ 'common.enabled' | translate }}</th>
+        </tr>
+    </thead>
+    <tbody>
+        <tr *ngFor="let variant of variants | paginate: paginationConfig; index as i; trackBy: trackByFn">
+            <ng-container *ngIf="formGroupMap.get(variant.id) as formGroup" [formGroup]="formGroup">
+                <td class="left align-middle" [class.disabled]="!formGroup.get('enabled')!.value">
+                    <div class="card-img">
+                        <div class="featured-asset">
+                            <img
+                                *ngIf="getFeaturedAsset(variant) as featuredAsset; else placeholder"
+                                [src]="featuredAsset | assetPreview: 'tiny'"
+                            />
+                            <ng-template #placeholder>
+                                <div class="placeholder">
+                                    <clr-icon shape="image" size="48"></clr-icon>
+                                </div>
+                            </ng-template>
+                        </div>
                     </div>
-                </div>
-            </td>
-            <td class="left align-middle" [class.disabled]="!formGroup.get('enabled')!.value">
-                <clr-input-container>
-                    <input
-                        clrInput
-                        type="text"
-                        formControlName="name"
-                        [readonly]="!(updatePermission | hasPermission)"
-                        [placeholder]="'common.name' | translate"
-                    />
-                </clr-input-container>
-            </td>
-            <td class="left align-middle" [class.disabled]="!formGroup.get('enabled')!.value">
-                <clr-input-container>
-                    <input
-                        clrInput
-                        type="text"
-                        formControlName="sku"
-                        [readonly]="!(updatePermission | hasPermission)"
-                        [placeholder]="'catalog.sku' | translate"
-                    />
-                </clr-input-container>
-            </td>
-            <ng-container *ngFor="let option of variant.options | sort: 'groupId'">
-                <td
-                    class="left align-middle"
-                    [class.disabled]="!formGroup.get('enabled')!.value"
-                    [style.color]="optionGroupName(option.groupId) | stringToColor"
-                >
-                    {{ option.name }}
+                </td>
+                <td class="left align-middle" [class.disabled]="!formGroup.get('enabled')!.value">
+                    <clr-input-container>
+                        <input
+                            clrInput
+                            type="text"
+                            formControlName="name"
+                            [readonly]="!(updatePermission | hasPermission)"
+                            [placeholder]="'common.name' | translate"
+                        />
+                    </clr-input-container>
+                </td>
+                <td class="left align-middle" [class.disabled]="!formGroup.get('enabled')!.value">
+                    <clr-input-container>
+                        <input
+                            clrInput
+                            type="text"
+                            formControlName="sku"
+                            [readonly]="!(updatePermission | hasPermission)"
+                            [placeholder]="'catalog.sku' | translate"
+                        />
+                    </clr-input-container>
+                </td>
+                <ng-container *ngFor="let option of variant.options | sort: 'groupId'">
+                    <td
+                        class="left align-middle"
+                        [class.disabled]="!formGroup.get('enabled')!.value"
+                        [style.color]="optionGroupName(option.groupId) | stringToColor"
+                    >
+                        {{ option.name }}
+                    </td>
+                </ng-container>
+                <td class="left align-middle price" [class.disabled]="!formGroup.get('enabled')!.value">
+                    <clr-input-container>
+                        <vdr-currency-input
+                            *ngIf="!channelPriceIncludesTax"
+                            clrInput
+                            [currencyCode]="variant.currencyCode"
+                            [readonly]="!(updatePermission | hasPermission)"
+                            formControlName="price"
+                        ></vdr-currency-input>
+                        <vdr-currency-input
+                            *ngIf="channelPriceIncludesTax"
+                            clrInput
+                            [currencyCode]="variant.currencyCode"
+                            [readonly]="!(updatePermission | hasPermission)"
+                            formControlName="priceWithTax"
+                        ></vdr-currency-input>
+                    </clr-input-container>
+                </td>
+                <td class="left align-middle stock" [class.disabled]="!formGroup.get('enabled')!.value">
+                    <clr-input-container>
+                        <input
+                            clrInput
+                            type="number"
+                            min="0"
+                            step="1"
+                            formControlName="stockOnHand"
+                            [readonly]="!(updatePermission | hasPermission)"
+                        />
+                    </clr-input-container>
+                </td>
+                <td class="left align-middle stock" [class.disabled]="!formGroup.get('enabled')!.value">
+                    <clr-toggle-wrapper>
+                        <input
+                            type="checkbox"
+                            clrToggle
+                            name="enabled"
+                            formControlName="enabled"
+                            [vdrDisabled]="!(updatePermission | hasPermission)"
+                        />
+                    </clr-toggle-wrapper>
                 </td>
             </ng-container>
-            <td class="left align-middle price" [class.disabled]="!formGroup.get('enabled')!.value">
-                <clr-input-container>
-                    <vdr-currency-input
-                        *ngIf="!channelPriceIncludesTax"
-                        clrInput
-                        [currencyCode]="variant.currencyCode"
-                        [readonly]="!(updatePermission | hasPermission)"
-                        formControlName="price"
-                    ></vdr-currency-input>
-                    <vdr-currency-input
-                        *ngIf="channelPriceIncludesTax"
-                        clrInput
-                        [currencyCode]="variant.currencyCode"
-                        [readonly]="!(updatePermission | hasPermission)"
-                        formControlName="priceWithTax"
-                    ></vdr-currency-input>
-                </clr-input-container>
-            </td>
-            <td class="left align-middle stock" [class.disabled]="!formGroup.get('enabled')!.value">
-                <clr-input-container>
-                    <input
-                        clrInput
-                        type="number"
-                        min="0"
-                        step="1"
-                        formControlName="stockOnHand"
-                        [readonly]="!(updatePermission | hasPermission)"
-                    />
-                </clr-input-container>
-            </td>
-            <td class="left align-middle stock" [class.disabled]="!formGroup.get('enabled')!.value">
-                <clr-toggle-wrapper>
-                    <input
-                        type="checkbox"
-                        clrToggle
-                        name="enabled"
-                        formControlName="enabled"
-                        [vdrDisabled]="!(updatePermission | hasPermission)"
-                    />
-                </clr-toggle-wrapper>
-            </td>
-        </ng-container>
-    </ng-template>
-</vdr-data-table>
+        </tr>
+    </tbody>
+</table>

+ 14 - 5
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts

@@ -7,11 +7,11 @@ import {
     OnInit,
 } from '@angular/core';
 import { FormArray, FormGroup } from '@angular/forms';
-import { Permission, ProductWithVariants } from '@vendure/admin-ui/core';
+import { Permission, ProductDetail, ProductVariant } from '@vendure/admin-ui/core';
 import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
-import { SelectedAssets } from '../product-detail/product-detail.component';
+import { PaginationConfig, SelectedAssets } from '../product-detail/product-detail.component';
 
 @Component({
     selector: 'vdr-product-variants-table',
@@ -21,9 +21,10 @@ import { SelectedAssets } from '../product-detail/product-detail.component';
 })
 export class ProductVariantsTableComponent implements OnInit, OnDestroy {
     @Input('productVariantsFormArray') formArray: FormArray;
-    @Input() variants: ProductWithVariants.Variants[];
+    @Input() variants: ProductVariant.Fragment[];
+    @Input() paginationConfig: PaginationConfig;
     @Input() channelPriceIncludesTax: boolean;
-    @Input() optionGroups: ProductWithVariants.OptionGroups[];
+    @Input() optionGroups: ProductDetail.OptionGroups[];
     @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
     formGroupMap = new Map<string, FormGroup>();
     readonly updatePermission = [Permission.UpdateCatalog, Permission.UpdateProduct];
@@ -51,7 +52,15 @@ export class ProductVariantsTableComponent implements OnInit, OnDestroy {
         }
     }
 
-    getFeaturedAsset(variant: ProductWithVariants.Variants) {
+    trackByFn(index: number, item: any) {
+        if ((item as any).id != null) {
+            return (item as any).id;
+        } else {
+            return index;
+        }
+    }
+
+    getFeaturedAsset(variant: ProductVariant.Fragment) {
         return this.pendingAssetChanges[variant.id]?.featuredAsset || variant.featuredAsset;
     }
 

+ 100 - 77
packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts

@@ -6,8 +6,8 @@ import {
     DeletionResult,
     FacetWithValues,
     findTranslation,
+    GetProductWithVariants,
     LanguageCode,
-    ProductWithVariants,
     UpdateProductInput,
     UpdateProductMutation,
     UpdateProductOptionInput,
@@ -151,7 +151,7 @@ export class ProductDetailService {
     }
 
     updateProduct(updateOptions: {
-        product: ProductWithVariants.Fragment;
+        product: GetProductWithVariants.Product;
         languageCode: LanguageCode;
         autoUpdate: boolean;
         productInput?: UpdateProductInput;
@@ -160,95 +160,118 @@ export class ProductDetailService {
         const { product, languageCode, autoUpdate, productInput, variantsInput } = updateOptions;
         const updateOperations: Array<Observable<UpdateProductMutation | UpdateProductVariantsMutation>> = [];
         const updateVariantsInput = variantsInput || [];
-        if (productInput) {
-            updateOperations.push(this.dataService.product.updateProduct(productInput));
 
-            const productOldName = findTranslation(product, languageCode)?.name ?? '';
-            const productNewName = findTranslation(productInput, languageCode)?.name;
-            if (productNewName && productOldName !== productNewName && autoUpdate) {
-                for (const variant of product.variants) {
-                    const currentVariantName = findTranslation(variant, languageCode)?.name || '';
-                    let variantInput: UpdateProductVariantInput;
-                    const existingVariantInput = updateVariantsInput.find(i => i.id === variant.id);
-                    if (existingVariantInput) {
-                        variantInput = existingVariantInput;
-                    } else {
-                        variantInput = {
-                            id: variant.id,
-                            translations: [{ languageCode, name: currentVariantName }],
-                        };
-                        updateVariantsInput.push(variantInput);
-                    }
-                    const variantTranslation = findTranslation(variantInput, languageCode);
-                    if (variantTranslation) {
-                        if (variantTranslation.name) {
-                            variantTranslation.name = replaceLast(
-                                variantTranslation.name,
-                                productOldName,
-                                productNewName,
-                            );
-                        } else {
-                            // The variant translation was falsy, which occurs
-                            // when defining the product name for a new translation
-                            // language that had not yet been defined.
-                            variantTranslation.name = [
-                                productNewName,
-                                ...variant.options.map(o => o.name),
-                            ].join(' ');
+        const variants$ = autoUpdate
+            ? this.dataService.product
+                  .getProductVariants({}, product.id)
+                  .mapSingle(({ productVariants }) => productVariants.items)
+            : of([]);
+
+        return variants$.pipe(
+            mergeMap(variants => {
+                if (productInput) {
+                    updateOperations.push(this.dataService.product.updateProduct(productInput));
+                    const productOldName = findTranslation(product, languageCode)?.name ?? '';
+                    const productNewName = findTranslation(productInput, languageCode)?.name;
+                    if (productNewName && productOldName !== productNewName && autoUpdate) {
+                        for (const variant of variants) {
+                            const currentVariantName = findTranslation(variant, languageCode)?.name || '';
+                            let variantInput: UpdateProductVariantInput;
+                            const existingVariantInput = updateVariantsInput.find(i => i.id === variant.id);
+                            if (existingVariantInput) {
+                                variantInput = existingVariantInput;
+                            } else {
+                                variantInput = {
+                                    id: variant.id,
+                                    translations: [{ languageCode, name: currentVariantName }],
+                                };
+                                updateVariantsInput.push(variantInput);
+                            }
+                            const variantTranslation = findTranslation(variantInput, languageCode);
+                            if (variantTranslation) {
+                                if (variantTranslation.name) {
+                                    variantTranslation.name = replaceLast(
+                                        variantTranslation.name,
+                                        productOldName,
+                                        productNewName,
+                                    );
+                                } else {
+                                    // The variant translation was falsy, which occurs
+                                    // when defining the product name for a new translation
+                                    // language that had not yet been defined.
+                                    variantTranslation.name = [
+                                        productNewName,
+                                        ...variant.options.map(o => o.name),
+                                    ].join(' ');
+                                }
+                            }
                         }
                     }
                 }
-            }
-        }
-        if (updateVariantsInput.length) {
-            updateOperations.push(this.dataService.product.updateProductVariants(updateVariantsInput));
-        }
-        return forkJoin(updateOperations);
+                if (updateVariantsInput.length) {
+                    updateOperations.push(
+                        this.dataService.product.updateProductVariants(updateVariantsInput),
+                    );
+                }
+                return forkJoin(updateOperations);
+            }),
+        );
     }
 
     updateProductOption(
         input: UpdateProductOptionInput & { autoUpdate: boolean },
-        product: ProductWithVariants.Fragment,
+        product: GetProductWithVariants.Product,
         languageCode: LanguageCode,
     ) {
-        let updateProductVariantNames$: Observable<any> = of([]);
-        if (input.autoUpdate) {
-            // Update any ProductVariants' names which include the option name
-            let oldOptionName: string | undefined;
-            const newOptionName = findTranslation(input, languageCode)?.name;
-            if (!newOptionName) {
-                updateProductVariantNames$ = of([]);
-            }
-            const variantsToUpdate: UpdateProductVariantInput[] = [];
-            for (const variant of product.variants) {
-                if (variant.options.map(o => o.id).includes(input.id)) {
-                    if (!oldOptionName) {
-                        oldOptionName = findTranslation(
-                            variant.options.find(o => o.id === input.id),
-                            languageCode,
-                        )?.name;
+        const variants$ = input.autoUpdate
+            ? this.dataService.product
+                  .getProductVariants({}, product.id)
+                  .mapSingle(({ productVariants }) => productVariants.items)
+            : of([]);
+
+        return variants$.pipe(
+            mergeMap(variants => {
+                let updateProductVariantNames$: Observable<any> = of([]);
+                if (input.autoUpdate) {
+                    // Update any ProductVariants' names which include the option name
+                    let oldOptionName: string | undefined;
+                    const newOptionName = findTranslation(input, languageCode)?.name;
+                    if (!newOptionName) {
+                        updateProductVariantNames$ = of([]);
                     }
-                    const variantName = findTranslation(variant, languageCode)?.name || '';
-                    if (oldOptionName && newOptionName && variantName.includes(oldOptionName)) {
-                        variantsToUpdate.push({
-                            id: variant.id,
-                            translations: [
-                                {
+                    const variantsToUpdate: UpdateProductVariantInput[] = [];
+                    for (const variant of variants) {
+                        if (variant.options.map(o => o.id).includes(input.id)) {
+                            if (!oldOptionName) {
+                                oldOptionName = findTranslation(
+                                    variant.options.find(o => o.id === input.id),
                                     languageCode,
-                                    name: replaceLast(variantName, oldOptionName, newOptionName),
-                                },
-                            ],
-                        });
+                                )?.name;
+                            }
+                            const variantName = findTranslation(variant, languageCode)?.name || '';
+                            if (oldOptionName && newOptionName && variantName.includes(oldOptionName)) {
+                                variantsToUpdate.push({
+                                    id: variant.id,
+                                    translations: [
+                                        {
+                                            languageCode,
+                                            name: replaceLast(variantName, oldOptionName, newOptionName),
+                                        },
+                                    ],
+                                });
+                            }
+                        }
+                    }
+                    if (variantsToUpdate.length) {
+                        updateProductVariantNames$ =
+                            this.dataService.product.updateProductVariants(variantsToUpdate);
                     }
                 }
-            }
-            if (variantsToUpdate.length) {
-                updateProductVariantNames$ = this.dataService.product.updateProductVariants(variantsToUpdate);
-            }
-        }
-        return this.dataService.product
-            .updateProductOption(input)
-            .pipe(mergeMap(() => updateProductVariantNames$));
+                return this.dataService.product
+                    .updateProductOption(input)
+                    .pipe(mergeMap(() => updateProductVariantNames$));
+            }),
+        );
     }
 
     deleteProductVariant(id: string, productId: string) {

+ 1 - 4
packages/admin-ui/src/lib/catalog/src/providers/routing/collection-resolver.ts

@@ -1,9 +1,6 @@
 import { Injectable } from '@angular/core';
 import { Router } from '@angular/router';
-import { BaseEntityResolver } from '@vendure/admin-ui/core';
-import { Collection, ProductWithVariants } from '@vendure/admin-ui/core';
-import { getDefaultUiLanguage } from '@vendure/admin-ui/core';
-import { DataService } from '@vendure/admin-ui/core';
+import { BaseEntityResolver, Collection, DataService, getDefaultUiLanguage } from '@vendure/admin-ui/core';
 
 @Injectable({
     providedIn: 'root',

+ 11 - 9
packages/admin-ui/src/lib/catalog/src/providers/routing/product-resolver.ts

@@ -1,14 +1,16 @@
 import { Injectable } from '@angular/core';
 import { Router } from '@angular/router';
-import { BaseEntityResolver } from '@vendure/admin-ui/core';
-import { ProductWithVariants } from '@vendure/admin-ui/core';
-import { getDefaultUiLanguage } from '@vendure/admin-ui/core';
-import { DataService } from '@vendure/admin-ui/core';
+import {
+    BaseEntityResolver,
+    DataService,
+    getDefaultUiLanguage,
+    GetProductWithVariants,
+} from '@vendure/admin-ui/core';
 
 @Injectable({
     providedIn: 'root',
 })
-export class ProductResolver extends BaseEntityResolver<ProductWithVariants.Fragment> {
+export class ProductResolver extends BaseEntityResolver<GetProductWithVariants.Product> {
     constructor(dataService: DataService, router: Router) {
         super(
             router,
@@ -27,14 +29,14 @@ export class ProductResolver extends BaseEntityResolver<ProductWithVariants.Frag
                 translations: [],
                 optionGroups: [],
                 facetValues: [],
-                variants: [],
+                variantList: { items: [], totalItems: 0 },
                 channels: [],
             },
-            (id) =>
+            id =>
                 dataService.product
-                    .getProduct(id)
+                    .getProduct(id, { take: 10 })
                     .refetchOnChannelChange()
-                    .mapStream((data) => data.product),
+                    .mapStream(data => data.product),
         );
     }
 }

+ 84 - 24
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -3579,7 +3579,10 @@ export type Product = Node & {
   description: Scalars['String'];
   featuredAsset?: Maybe<Asset>;
   assets: Array<Asset>;
+  /** Returns all ProductVariants */
   variants: Array<ProductVariant>;
+  /** Returns a paginated, sortable, filterable list of ProductVariants */
+  variantList: ProductVariantList;
   optionGroups: Array<ProductOptionGroup>;
   facetValues: Array<FacetValue>;
   translations: Array<ProductTranslation>;
@@ -3587,6 +3590,11 @@ export type Product = Node & {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+
+export type ProductVariantListArgs = {
+  options?: Maybe<ProductVariantListOptions>;
+};
+
 export type ProductFilterParameter = {
   enabled?: Maybe<BooleanOperators>;
   createdAt?: Maybe<DateOperators>;
@@ -6361,7 +6369,7 @@ export type ProductVariantFragment = (
   )> }
 );
 
-export type ProductWithVariantsFragment = (
+export type ProductDetailFragment = (
   { __typename?: 'Product' }
   & Pick<Product, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'languageCode' | 'name' | 'slug' | 'description'>
   & { featuredAsset?: Maybe<(
@@ -6376,9 +6384,6 @@ export type ProductWithVariantsFragment = (
   )>, optionGroups: Array<(
     { __typename?: 'ProductOptionGroup' }
     & ProductOptionGroupFragment
-  )>, variants: Array<(
-    { __typename?: 'ProductVariant' }
-    & ProductVariantFragment
   )>, facetValues: Array<(
     { __typename?: 'FacetValue' }
     & Pick<FacetValue, 'id' | 'code' | 'name'>
@@ -6410,22 +6415,40 @@ export type ProductOptionGroupWithOptionsFragment = (
 
 export type UpdateProductMutationVariables = Exact<{
   input: UpdateProductInput;
+  variantListOptions?: Maybe<ProductVariantListOptions>;
 }>;
 
 
 export type UpdateProductMutation = { updateProduct: (
     { __typename?: 'Product' }
-    & ProductWithVariantsFragment
+    & { variantList: (
+      { __typename?: 'ProductVariantList' }
+      & Pick<ProductVariantList, 'totalItems'>
+      & { items: Array<(
+        { __typename?: 'ProductVariant' }
+        & ProductVariantFragment
+      )> }
+    ) }
+    & ProductDetailFragment
   ) };
 
 export type CreateProductMutationVariables = Exact<{
   input: CreateProductInput;
+  variantListOptions?: Maybe<ProductVariantListOptions>;
 }>;
 
 
 export type CreateProductMutation = { createProduct: (
     { __typename?: 'Product' }
-    & ProductWithVariantsFragment
+    & { variantList: (
+      { __typename?: 'ProductVariantList' }
+      & Pick<ProductVariantList, 'totalItems'>
+      & { items: Array<(
+        { __typename?: 'ProductVariant' }
+        & ProductVariantFragment
+      )> }
+    ) }
+    & ProductDetailFragment
   ) };
 
 export type DeleteProductMutationVariables = Exact<{
@@ -6531,12 +6554,21 @@ export type RemoveOptionGroupFromProductMutation = { removeOptionGroupFromProduc
 
 export type GetProductWithVariantsQueryVariables = Exact<{
   id: Scalars['ID'];
+  variantListOptions?: Maybe<ProductVariantListOptions>;
 }>;
 
 
 export type GetProductWithVariantsQuery = { product?: Maybe<(
     { __typename?: 'Product' }
-    & ProductWithVariantsFragment
+    & { variantList: (
+      { __typename?: 'ProductVariantList' }
+      & Pick<ProductVariantList, 'totalItems'>
+      & { items: Array<(
+        { __typename?: 'ProductVariant' }
+        & ProductVariantFragment
+      )> }
+    ) }
+    & ProductDetailFragment
   )> };
 
 export type GetProductSimpleQueryVariables = Exact<{
@@ -6866,12 +6898,13 @@ export type GetProductVariantQuery = { productVariant?: Maybe<(
     ) }
   )> };
 
-export type GetProductVariantListQueryVariables = Exact<{
+export type GetProductVariantListSimpleQueryVariables = Exact<{
   options: ProductVariantListOptions;
+  productId?: Maybe<Scalars['ID']>;
 }>;
 
 
-export type GetProductVariantListQuery = { productVariants: (
+export type GetProductVariantListSimpleQuery = { productVariants: (
     { __typename?: 'ProductVariantList' }
     & Pick<ProductVariantList, 'totalItems'>
     & { items: Array<(
@@ -6899,6 +6932,21 @@ export type GetProductVariantListQuery = { productVariants: (
     )> }
   ) };
 
+export type GetProductVariantListQueryVariables = Exact<{
+  options: ProductVariantListOptions;
+  productId?: Maybe<Scalars['ID']>;
+}>;
+
+
+export type GetProductVariantListQuery = { productVariants: (
+    { __typename?: 'ProductVariantList' }
+    & Pick<ProductVariantList, 'totalItems'>
+    & { items: Array<(
+      { __typename?: 'ProductVariant' }
+      & ProductVariantFragment
+    )> }
+  ) };
+
 export type GetTagListQueryVariables = Exact<{
   options?: Maybe<TagListOptions>;
 }>;
@@ -9166,16 +9214,15 @@ export namespace ProductVariant {
   export type Channels = NonNullable<(NonNullable<ProductVariantFragment['channels']>)[number]>;
 }
 
-export namespace ProductWithVariants {
-  export type Fragment = ProductWithVariantsFragment;
-  export type FeaturedAsset = (NonNullable<ProductWithVariantsFragment['featuredAsset']>);
-  export type Assets = NonNullable<(NonNullable<ProductWithVariantsFragment['assets']>)[number]>;
-  export type Translations = NonNullable<(NonNullable<ProductWithVariantsFragment['translations']>)[number]>;
-  export type OptionGroups = NonNullable<(NonNullable<ProductWithVariantsFragment['optionGroups']>)[number]>;
-  export type Variants = NonNullable<(NonNullable<ProductWithVariantsFragment['variants']>)[number]>;
-  export type FacetValues = NonNullable<(NonNullable<ProductWithVariantsFragment['facetValues']>)[number]>;
-  export type Facet = (NonNullable<NonNullable<(NonNullable<ProductWithVariantsFragment['facetValues']>)[number]>['facet']>);
-  export type Channels = NonNullable<(NonNullable<ProductWithVariantsFragment['channels']>)[number]>;
+export namespace ProductDetail {
+  export type Fragment = ProductDetailFragment;
+  export type FeaturedAsset = (NonNullable<ProductDetailFragment['featuredAsset']>);
+  export type Assets = NonNullable<(NonNullable<ProductDetailFragment['assets']>)[number]>;
+  export type Translations = NonNullable<(NonNullable<ProductDetailFragment['translations']>)[number]>;
+  export type OptionGroups = NonNullable<(NonNullable<ProductDetailFragment['optionGroups']>)[number]>;
+  export type FacetValues = NonNullable<(NonNullable<ProductDetailFragment['facetValues']>)[number]>;
+  export type Facet = (NonNullable<NonNullable<(NonNullable<ProductDetailFragment['facetValues']>)[number]>['facet']>);
+  export type Channels = NonNullable<(NonNullable<ProductDetailFragment['channels']>)[number]>;
 }
 
 export namespace ProductOptionGroupWithOptions {
@@ -9189,12 +9236,16 @@ export namespace UpdateProduct {
   export type Variables = UpdateProductMutationVariables;
   export type Mutation = UpdateProductMutation;
   export type UpdateProduct = (NonNullable<UpdateProductMutation['updateProduct']>);
+  export type VariantList = (NonNullable<(NonNullable<UpdateProductMutation['updateProduct']>)['variantList']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<(NonNullable<UpdateProductMutation['updateProduct']>)['variantList']>)['items']>)[number]>;
 }
 
 export namespace CreateProduct {
   export type Variables = CreateProductMutationVariables;
   export type Mutation = CreateProductMutation;
   export type CreateProduct = (NonNullable<CreateProductMutation['createProduct']>);
+  export type VariantList = (NonNullable<(NonNullable<CreateProductMutation['createProduct']>)['variantList']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<(NonNullable<CreateProductMutation['createProduct']>)['variantList']>)['items']>)[number]>;
 }
 
 export namespace DeleteProduct {
@@ -9254,6 +9305,8 @@ export namespace GetProductWithVariants {
   export type Variables = GetProductWithVariantsQueryVariables;
   export type Query = GetProductWithVariantsQuery;
   export type Product = (NonNullable<GetProductWithVariantsQuery['product']>);
+  export type VariantList = (NonNullable<(NonNullable<GetProductWithVariantsQuery['product']>)['variantList']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<(NonNullable<GetProductWithVariantsQuery['product']>)['variantList']>)['items']>)[number]>;
 }
 
 export namespace GetProductSimple {
@@ -9409,16 +9462,23 @@ export namespace GetProductVariant {
   export type _FocalPoint = (NonNullable<(NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>)['featuredAsset']>)['focalPoint']>);
 }
 
+export namespace GetProductVariantListSimple {
+  export type Variables = GetProductVariantListSimpleQueryVariables;
+  export type Query = GetProductVariantListSimpleQuery;
+  export type ProductVariants = (NonNullable<GetProductVariantListSimpleQuery['productVariants']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<GetProductVariantListSimpleQuery['productVariants']>)['items']>)[number]>;
+  export type FeaturedAsset = (NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListSimpleQuery['productVariants']>)['items']>)[number]>['featuredAsset']>);
+  export type FocalPoint = (NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListSimpleQuery['productVariants']>)['items']>)[number]>['featuredAsset']>)['focalPoint']>);
+  export type Product = (NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListSimpleQuery['productVariants']>)['items']>)[number]>['product']>);
+  export type _FeaturedAsset = (NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListSimpleQuery['productVariants']>)['items']>)[number]>['product']>)['featuredAsset']>);
+  export type _FocalPoint = (NonNullable<(NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListSimpleQuery['productVariants']>)['items']>)[number]>['product']>)['featuredAsset']>)['focalPoint']>);
+}
+
 export namespace GetProductVariantList {
   export type Variables = GetProductVariantListQueryVariables;
   export type Query = GetProductVariantListQuery;
   export type ProductVariants = (NonNullable<GetProductVariantListQuery['productVariants']>);
   export type Items = NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>;
-  export type FeaturedAsset = (NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['featuredAsset']>);
-  export type FocalPoint = (NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['featuredAsset']>)['focalPoint']>);
-  export type Product = (NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['product']>);
-  export type _FeaturedAsset = (NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['product']>)['featuredAsset']>);
-  export type _FocalPoint = (NonNullable<(NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['product']>)['featuredAsset']>)['focalPoint']>);
 }
 
 export namespace GetTagList {

+ 47 - 18
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -120,8 +120,8 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
     ${ASSET_FRAGMENT}
 `;
 
-export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
-    fragment ProductWithVariants on Product {
+export const PRODUCT_DETAIL_FRAGMENT = gql`
+    fragment ProductDetail on Product {
         id
         createdAt
         updatedAt
@@ -146,9 +146,6 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         optionGroups {
             ...ProductOptionGroup
         }
-        variants {
-            ...ProductVariant
-        }
         facetValues {
             id
             code
@@ -164,7 +161,6 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         }
     }
     ${PRODUCT_OPTION_GROUP_FRAGMENT}
-    ${PRODUCT_VARIANT_FRAGMENT}
     ${ASSET_FRAGMENT}
 `;
 
@@ -193,21 +189,35 @@ export const PRODUCT_OPTION_GROUP_WITH_OPTIONS_FRAGMENT = gql`
 `;
 
 export const UPDATE_PRODUCT = gql`
-    mutation UpdateProduct($input: UpdateProductInput!) {
+    mutation UpdateProduct($input: UpdateProductInput!, $variantListOptions: ProductVariantListOptions) {
         updateProduct(input: $input) {
-            ...ProductWithVariants
+            ...ProductDetail
+            variantList(options: $variantListOptions) {
+                items {
+                    ...ProductVariant
+                }
+                totalItems
+            }
         }
     }
-    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
+    ${PRODUCT_DETAIL_FRAGMENT}
+    ${PRODUCT_VARIANT_FRAGMENT}
 `;
 
 export const CREATE_PRODUCT = gql`
-    mutation CreateProduct($input: CreateProductInput!) {
+    mutation CreateProduct($input: CreateProductInput!, $variantListOptions: ProductVariantListOptions) {
         createProduct(input: $input) {
-            ...ProductWithVariants
+            ...ProductDetail
+            variantList(options: $variantListOptions) {
+                items {
+                    ...ProductVariant
+                }
+                totalItems
+            }
         }
     }
-    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
+    ${PRODUCT_DETAIL_FRAGMENT}
+    ${PRODUCT_VARIANT_FRAGMENT}
 `;
 
 export const DELETE_PRODUCT = gql`
@@ -317,12 +327,19 @@ export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
 `;
 
 export const GET_PRODUCT_WITH_VARIANTS = gql`
-    query GetProductWithVariants($id: ID!) {
+    query GetProductWithVariants($id: ID!, $variantListOptions: ProductVariantListOptions) {
         product(id: $id) {
-            ...ProductWithVariants
+            ...ProductDetail
+            variantList(options: $variantListOptions) {
+                items {
+                    ...ProductVariant
+                }
+                totalItems
+            }
         }
     }
-    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
+    ${PRODUCT_DETAIL_FRAGMENT}
+    ${PRODUCT_VARIANT_FRAGMENT}
 `;
 
 export const GET_PRODUCT_SIMPLE = gql`
@@ -671,9 +688,9 @@ export const GET_PRODUCT_VARIANT = gql`
     }
 `;
 
-export const GET_PRODUCT_VARIANT_LIST = gql`
-    query GetProductVariantList($options: ProductVariantListOptions!) {
-        productVariants(options: $options) {
+export const GET_PRODUCT_VARIANT_LIST_SIMPLE = gql`
+    query GetProductVariantListSimple($options: ProductVariantListOptions!, $productId: ID) {
+        productVariants(options: $options, productId: $productId) {
             items {
                 id
                 name
@@ -703,6 +720,18 @@ export const GET_PRODUCT_VARIANT_LIST = gql`
     }
 `;
 
+export const GET_PRODUCT_VARIANT_LIST = gql`
+    query GetProductVariantList($options: ProductVariantListOptions!, $productId: ID) {
+        productVariants(options: $options, productId: $productId) {
+            items {
+                ...ProductVariant
+            }
+            totalItems
+        }
+    }
+    ${PRODUCT_VARIANT_FRAGMENT}
+`;
+
 export const GET_TAG_LIST = gql`
     query GetTagList($options: TagListOptions) {
         tags(options: $options) {

+ 13 - 3
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -29,6 +29,7 @@ import {
     GetProductSimple,
     GetProductVariant,
     GetProductVariantList,
+    GetProductVariantListSimple,
     GetProductVariantOptions,
     GetProductWithVariants,
     GetTag,
@@ -80,6 +81,7 @@ import {
     GET_PRODUCT_SIMPLE,
     GET_PRODUCT_VARIANT,
     GET_PRODUCT_VARIANT_LIST,
+    GET_PRODUCT_VARIANT_LIST_SIMPLE,
     GET_PRODUCT_VARIANT_OPTIONS,
     GET_PRODUCT_WITH_VARIANTS,
     GET_TAG,
@@ -134,11 +136,12 @@ export class ProductDataService {
         });
     }
 
-    getProduct(id: string) {
+    getProduct(id: string, variantListOptions?: ProductVariantListOptions) {
         return this.baseDataService.query<GetProductWithVariants.Query, GetProductWithVariants.Variables>(
             GET_PRODUCT_WITH_VARIANTS,
             {
                 id,
+                variantListOptions,
             },
         );
     }
@@ -152,10 +155,17 @@ export class ProductDataService {
         );
     }
 
-    getProductVariants(options: ProductVariantListOptions) {
+    getProductVariantsSimple(options: ProductVariantListOptions, productId?: string) {
+        return this.baseDataService.query<
+            GetProductVariantListSimple.Query,
+            GetProductVariantListSimple.Variables
+        >(GET_PRODUCT_VARIANT_LIST_SIMPLE, { options, productId });
+    }
+
+    getProductVariants(options: ProductVariantListOptions, productId?: string) {
         return this.baseDataService.query<GetProductVariantList.Query, GetProductVariantList.Variables>(
             GET_PRODUCT_VARIANT_LIST,
-            { options },
+            { options, productId },
         );
     }
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/pagination-controls/pagination-controls.component.html

@@ -1,4 +1,4 @@
-<pagination-template #p="paginationApi" (pageChange)="pageChange.emit($event)">
+<pagination-template #p="paginationApi" (pageChange)="pageChange.emit($event)" [id]="id">
     <ul>
         <li class="pagination-previous">
             <a *ngIf="!p.isFirstPage()" (click)="p.previous()" (keyup.enter)="p.previous()" tabindex="0">«</a>

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/pagination-controls/pagination-controls.component.ts

@@ -7,6 +7,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class PaginationControlsComponent {
+    @Input() id?: number;
     @Input() currentPage: number;
     @Input() itemsPerPage: number;
     @Input() totalItems: number;

+ 3 - 2
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.ts

@@ -7,6 +7,7 @@ import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'r
 import {
     GetProductVariant,
     GetProductVariantList,
+    GetProductVariantListSimple,
     RelationCustomFieldConfig,
 } from '../../../../common/generated-types';
 import { DataService } from '../../../../data/providers/data.service';
@@ -28,7 +29,7 @@ export class RelationProductVariantInputComponent implements OnInit {
 
     searchControl = new FormControl('');
     searchTerm$ = new Subject<string>();
-    results$: Observable<GetProductVariantList.Items[]>;
+    results$: Observable<GetProductVariantListSimple.Items[]>;
     productVariant$: Observable<GetProductVariant.ProductVariant | undefined>;
 
     constructor(private modalService: ModalService, private dataService: DataService) {}
@@ -53,7 +54,7 @@ export class RelationProductVariantInputComponent implements OnInit {
             debounceTime(200),
             switchMap(term => {
                 return this.dataService.product
-                    .getProductVariants({
+                    .getProductVariantsSimple({
                         ...(term
                             ? {
                                   filter: {