فهرست منبع

feat(admin-ui): Create new Page component for extensible tab routing

Michael Bromley 2 سال پیش
والد
کامیت
5f3f2f4b39
40فایلهای تغییر یافته به همراه780 افزوده شده و 238 حذف شده
  1. 17 17
      packages/admin-ui/i18n-coverage.json
  2. 35 9
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  3. 39 28
      packages/admin-ui/src/lib/catalog/src/catalog.routes.ts
  4. 23 14
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list-bulk-actions.ts
  5. 95 123
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html
  6. 13 21
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts
  7. 68 0
      packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-list-bulk-actions.ts
  8. 119 0
      packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.html
  9. 57 0
      packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.scss
  10. 133 0
      packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.ts
  11. 5 0
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  12. 9 7
      packages/admin-ui/src/lib/core/src/common/component-registry-types.ts
  13. 31 2
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  14. 1 1
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts
  15. 6 4
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.ts
  16. 43 0
      packages/admin-ui/src/lib/core/src/providers/page/page.service.ts
  17. 4 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  18. 5 1
      packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.scss
  19. 8 2
      packages/admin-ui/src/lib/core/src/shared/components/page-header-tabs/page-header-tabs.component.html
  20. 2 9
      packages/admin-ui/src/lib/core/src/shared/components/page-header-tabs/page-header-tabs.component.ts
  21. 10 0
      packages/admin-ui/src/lib/core/src/shared/components/page/page.component.html
  22. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/page/page.component.scss
  23. 27 0
      packages/admin-ui/src/lib/core/src/shared/components/page/page.component.ts
  24. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  25. 3 0
      packages/admin-ui/src/lib/customer/src/public_api.ts
  26. 1 0
      packages/admin-ui/src/lib/marketing/src/public_api.ts
  27. 11 0
      packages/admin-ui/src/lib/settings/src/public_api.ts
  28. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  29. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  30. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  31. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  32. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  33. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  34. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  35. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  36. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  37. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  38. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  39. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  40. 1 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

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

@@ -1,69 +1,69 @@
 {
-  "generatedOn": "2023-05-11T18:09:21.483Z",
-  "lastCommit": "7cce542211604d55d932d0e5c2610a0f19cb2581",
+  "generatedOn": "2023-05-12T08:21:18.191Z",
+  "lastCommit": "cac8b3a037147744054096663dbb29bd0830344f",
   "translationStatus": {
     "cs": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 566,
       "percentage": 81
     },
     "de": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 549,
       "percentage": 78
     },
     "en": {
-      "tokenCount": 702,
-      "translatedCount": 699,
+      "tokenCount": 703,
+      "translatedCount": 702,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 594,
-      "percentage": 85
+      "percentage": 84
     },
     "fr": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 586,
       "percentage": 83
     },
     "it": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 592,
       "percentage": 84
     },
     "pl": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 393,
       "percentage": 56
     },
     "pt_BR": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 564,
       "percentage": 80
     },
     "pt_PT": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 602,
       "percentage": 86
     },
     "ru": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 591,
       "percentage": 84
     },
     "uk": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 591,
       "percentage": 84
     },
     "zh_Hans": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 534,
       "percentage": 76
     },
     "zh_Hant": {
-      "tokenCount": 702,
+      "tokenCount": 703,
       "translatedCount": 373,
       "percentage": 53
     }

+ 35 - 9
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -1,9 +1,9 @@
 import { NgModule } from '@angular/core';
-import { RouterModule } from '@angular/router';
-import { BulkActionRegistryService, SharedModule } from '@vendure/admin-ui/core';
-import { deleteCustomersBulkAction } from '../../customer/src/components/customer-list/customer-list-bulk-actions';
+import { RouterModule, ROUTES } from '@angular/router';
+import { BulkActionRegistryService, SharedModule, PageService } from '@vendure/admin-ui/core';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 
-import { catalogRoutes } from './catalog.routes';
+import { createRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
 import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
@@ -12,7 +12,9 @@ import { AssignProductsToChannelDialogComponent } from './components/assign-prod
 import { AssignToChannelDialogComponent } from './components/assign-to-channel-dialog/assign-to-channel-dialog.component';
 import { BulkAddFacetValuesDialogComponent } from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 import { CollectionContentsComponent } from './components/collection-contents/collection-contents.component';
+import { CollectionDataTableComponent } from './components/collection-data-table/collection-data-table.component';
 import { CollectionDetailComponent } from './components/collection-detail/collection-detail.component';
+import { CollectionBreadcrumbPipe } from './components/collection-list/collection-breadcrumb.pipe';
 import {
     assignCollectionsToChannelBulkAction,
     deleteCollectionsBulkAction,
@@ -31,6 +33,7 @@ import {
 } from './components/facet-list/facet-list-bulk-actions';
 import { FacetListComponent } from './components/facet-list/facet-list.component';
 import { GenerateProductVariantsComponent } from './components/generate-product-variants/generate-product-variants.component';
+import { MoveCollectionsDialogComponent } from './components/move-collections-dialog/move-collections-dialog.component';
 import { OptionValueInputComponent } from './components/option-value-input/option-value-input.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import {
@@ -41,14 +44,12 @@ import {
 } from './components/product-list/product-list-bulk-actions';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductOptionsEditorComponent } from './components/product-options-editor/product-options-editor.component';
+import { ProductVariantListComponent } from './components/product-variant-list/product-variant-list.component';
 import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
 import { ProductVariantsListComponent } from './components/product-variants-list/product-variants-list.component';
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
 import { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.component';
 import { VariantPriceDetailComponent } from './components/variant-price-detail/variant-price-detail.component';
-import { CollectionDataTableComponent } from './components/collection-data-table/collection-data-table.component';
-import { CollectionBreadcrumbPipe } from './components/collection-list/collection-breadcrumb.pipe';
-import { MoveCollectionsDialogComponent } from './components/move-collections-dialog/move-collections-dialog.component';
 
 const CATALOG_COMPONENTS = [
     ProductListComponent,
@@ -79,15 +80,27 @@ const CATALOG_COMPONENTS = [
     CollectionDataTableComponent,
     CollectionBreadcrumbPipe,
     MoveCollectionsDialogComponent,
+    ProductVariantListComponent,
 ];
 
 @NgModule({
-    imports: [SharedModule, RouterModule.forChild(catalogRoutes)],
+    imports: [SharedModule, RouterModule.forChild([])],
     exports: [...CATALOG_COMPONENTS],
     declarations: [...CATALOG_COMPONENTS],
+    providers: [
+        {
+            provide: ROUTES,
+            useFactory: (pageService: PageService) => createRoutes(pageService),
+            multi: true,
+            deps: [PageService],
+        },
+    ],
 })
 export class CatalogModule {
-    constructor(private bulkActionRegistryService: BulkActionRegistryService) {
+    constructor(
+        private bulkActionRegistryService: BulkActionRegistryService,
+        private pageService: PageService,
+    ) {
         bulkActionRegistryService.registerBulkAction(assignFacetValuesToProductsBulkAction);
         bulkActionRegistryService.registerBulkAction(assignProductsToChannelBulkAction);
         bulkActionRegistryService.registerBulkAction(removeProductsFromChannelBulkAction);
@@ -101,5 +114,18 @@ export class CatalogModule {
         bulkActionRegistryService.registerBulkAction(removeCollectionsFromChannelBulkAction);
         bulkActionRegistryService.registerBulkAction(deleteCollectionsBulkAction);
         bulkActionRegistryService.registerBulkAction(moveCollectionsBulkAction);
+
+        pageService.registerPageTab({
+            location: 'product-list',
+            tab: _('catalog.products'),
+            route: '',
+            component: ProductListComponent,
+        });
+        pageService.registerPageTab({
+            location: 'product-list',
+            tab: _('catalog.product-variants'),
+            route: 'variants',
+            component: ProductVariantListComponent,
+        });
     }
 }

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

@@ -10,6 +10,8 @@ import {
     GetProductWithVariantsQuery,
 } from '@vendure/admin-ui/core';
 import { map } from 'rxjs/operators';
+import { PageService } from '../../core/src/providers/page/page.service';
+import { PageComponent } from '../../core/src/shared/components/page/page.component';
 
 import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
@@ -27,13 +29,15 @@ import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
 import { ProductVariantsResolver } from './providers/routing/product-variants-resolver';
 
-export const catalogRoutes: Route[] = [
+export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: 'products',
-        component: ProductListComponent,
+        component: PageComponent,
         data: {
+            locationId: 'product-list',
             breadcrumb: _('breadcrumb.products'),
         },
+        children: pageService.getPageTabRoutes('product-list'),
     },
     {
         path: 'products/:id',
@@ -43,6 +47,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: productBreadcrumb,
         },
+        children: pageService.getPageTabRoutes('product-detail'),
     },
     {
         path: 'products/:id/manage-variants',
@@ -68,6 +73,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: _('breadcrumb.facets'),
         },
+        children: pageService.getPageTabRoutes('facet-list'),
     },
     {
         path: 'facets/:id',
@@ -77,6 +83,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: facetBreadcrumb,
         },
+        children: pageService.getPageTabRoutes('facet-detail'),
     },
     {
         path: 'collections',
@@ -84,6 +91,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: _('breadcrumb.collections'),
         },
+        children: pageService.getPageTabRoutes('collection-list'),
     },
     {
         path: 'collections/:id',
@@ -93,6 +101,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: collectionBreadcrumb,
         },
+        children: pageService.getPageTabRoutes('collection-detail'),
     },
     {
         path: 'assets',
@@ -100,6 +109,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: _('breadcrumb.assets'),
         },
+        children: pageService.getPageTabRoutes('asset-list'),
     },
     {
         path: 'assets/:id',
@@ -108,6 +118,7 @@ export const catalogRoutes: Route[] = [
         data: {
             breadcrumb: assetBreadcrumb,
         },
+        children: pageService.getPageTabRoutes('asset-detail'),
     },
 ];
 
@@ -124,38 +135,38 @@ export function productBreadcrumb(data: any, params: any) {
 export function productVariantEditorBreadcrumb(data: any, params: any) {
     return data.entity.pipe(
         map((entity: any) => [
-                {
-                    label: _('breadcrumb.products'),
-                    link: ['../', 'products'],
-                },
-                {
-                    label: `${entity.name}`,
-                    link: ['../', 'products', params.id, { tab: 'variants' }],
-                },
-                {
-                    label: _('breadcrumb.manage-variants'),
-                    link: ['manage-variants'],
-                },
-            ]),
+            {
+                label: _('breadcrumb.products'),
+                link: ['../', 'products'],
+            },
+            {
+                label: `${entity.name}`,
+                link: ['../', 'products', params.id, { tab: 'variants' }],
+            },
+            {
+                label: _('breadcrumb.manage-variants'),
+                link: ['manage-variants'],
+            },
+        ]),
     );
 }
 
 export function productOptionsEditorBreadcrumb(data: any, params: any) {
     return data.entity.pipe(
         map((entity: any) => [
-                {
-                    label: _('breadcrumb.products'),
-                    link: ['../', 'products'],
-                },
-                {
-                    label: `${entity.name}`,
-                    link: ['../', 'products', params.id, { tab: 'variants' }],
-                },
-                {
-                    label: _('breadcrumb.product-options'),
-                    link: ['options'],
-                },
-            ]),
+            {
+                label: _('breadcrumb.products'),
+                link: ['../', 'products'],
+            },
+            {
+                label: `${entity.name}`,
+                link: ['../', 'products', params.id, { tab: 'variants' }],
+            },
+            {
+                label: _('breadcrumb.product-options'),
+                link: ['options'],
+            },
+        ]),
     );
 }
 

+ 23 - 14
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list-bulk-actions.ts

@@ -5,7 +5,9 @@ import {
     DataService,
     DeletionResult,
     getChannelCodeFromUserStatus,
+    GetProductListQuery,
     isMultiChannel,
+    ItemOf,
     ModalService,
     NotificationService,
     Permission,
@@ -17,9 +19,12 @@ import { mapTo, switchMap } from 'rxjs/operators';
 import { AssignProductsToChannelDialogComponent } from '../assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { BulkAddFacetValuesDialogComponent } from '../bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 
-import { ProductListComponent, SearchItem } from './product-list.component';
+import { ProductListComponent } from './product-list.component';
 
-export const deleteProductsBulkAction: BulkAction<SearchItem, ProductListComponent> = {
+export const deleteProductsBulkAction: BulkAction<
+    ItemOf<GetProductListQuery, 'products'>,
+    ProductListComponent
+> = {
     location: 'product-list',
     label: _('common.delete'),
     icon: 'trash',
@@ -44,9 +49,7 @@ export const deleteProductsBulkAction: BulkAction<SearchItem, ProductListCompone
             })
             .pipe(
                 switchMap(response =>
-                    response
-                        ? dataService.product.deleteProducts(unique(selection.map(p => p.productId)))
-                        : EMPTY,
+                    response ? dataService.product.deleteProducts(unique(selection.map(p => p.id))) : EMPTY,
                 ),
             )
             .subscribe(result => {
@@ -73,7 +76,10 @@ export const deleteProductsBulkAction: BulkAction<SearchItem, ProductListCompone
     },
 };
 
-export const assignProductsToChannelBulkAction: BulkAction<SearchItem, ProductListComponent> = {
+export const assignProductsToChannelBulkAction: BulkAction<
+    ItemOf<GetProductListQuery, 'products'>,
+    ProductListComponent
+> = {
     location: 'product-list',
     label: _('catalog.assign-to-channel'),
     icon: 'layers',
@@ -89,7 +95,7 @@ export const assignProductsToChannelBulkAction: BulkAction<SearchItem, ProductLi
             .fromComponent(AssignProductsToChannelDialogComponent, {
                 size: 'lg',
                 locals: {
-                    productIds: unique(selection.map(p => p.productId)),
+                    productIds: unique(selection.map(p => p.id)),
                     currentChannelIds: [],
                 },
             })
@@ -101,7 +107,10 @@ export const assignProductsToChannelBulkAction: BulkAction<SearchItem, ProductLi
     },
 };
 
-export const removeProductsFromChannelBulkAction: BulkAction<SearchItem, ProductListComponent> = {
+export const removeProductsFromChannelBulkAction: BulkAction<
+    ItemOf<GetProductListQuery, 'products'>,
+    ProductListComponent
+> = {
     location: 'product-list',
     label: _('catalog.remove-from-channel'),
     requiresPermission: userPermissions =>
@@ -144,7 +153,7 @@ export const removeProductsFromChannelBulkAction: BulkAction<SearchItem, Product
                                   activeChannelId
                                       ? dataService.product.removeProductsFromChannel({
                                             channelId: activeChannelId,
-                                            productIds: selection.map(p => p.productId),
+                                            productIds: selection.map(p => p.id),
                                         })
                                       : EMPTY,
                               ),
@@ -165,7 +174,10 @@ export const removeProductsFromChannelBulkAction: BulkAction<SearchItem, Product
     },
 };
 
-export const assignFacetValuesToProductsBulkAction: BulkAction<SearchItem, ProductListComponent> = {
+export const assignFacetValuesToProductsBulkAction: BulkAction<
+    ItemOf<GetProductListQuery, 'products'>,
+    ProductListComponent
+> = {
     location: 'product-list',
     label: _('catalog.edit-facet-values'),
     icon: 'tag',
@@ -177,10 +189,7 @@ export const assignFacetValuesToProductsBulkAction: BulkAction<SearchItem, Produ
         const dataService = injector.get(DataService);
         const notificationService = injector.get(NotificationService);
         const mode: 'product' | 'variant' = hostComponent.groupByProduct ? 'product' : 'variant';
-        const ids =
-            mode === 'product'
-                ? unique(selection.map(p => p.productId))
-                : unique(selection.map(p => p.productVariantId));
+        const ids = unique(selection.map(p => p.id));
         return modalService
             .fromComponent(BulkAddFacetValuesDialogComponent, {
                 size: 'xl',

+ 95 - 123
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html

@@ -1,54 +1,27 @@
-<vdr-page-header>
-    <vdr-page-title>
-        <vdr-action-bar-items locationId="product-list"></vdr-action-bar-items>
-        <a
-            class="btn btn-primary"
-            [routerLink]="['./create']"
-            *vdrIfPermissions="['CreateCatalog', 'CreateProduct']"
-        >
-            <clr-icon shape="plus"></clr-icon>
-            {{ 'catalog.create-new-product' | translate }}
-        </a>
-    </vdr-page-title>
-       <!--<vdr-page-header-description>Description of the current page (if applicable)</vdr-page-header-description>-->
-         <vdr-page-header-tabs
-             [tabs]="[
-                 { id: 'products', label: 'catalog.products' | translate },
-                 { id: 'variants', label: 'catalog.product-variants' | translate }
-             ]"
-         ></vdr-page-header-tabs>
-</vdr-page-header>
-<vdr-page-body>
-    <div class="flex wrap ml-4">
-        <!-- <clr-toggle-wrapper class="mt-2">
-            <input type="checkbox" clrToggle [(ngModel)]="groupByProduct" (ngModelChange)="refresh()" />
-            <label>
-                {{ 'catalog.group-by-product' | translate }}
-            </label>
-        </clr-toggle-wrapper>-->
-        <vdr-language-selector
-            [availableLanguageCodes]="availableLanguages$ | async"
-            [currentLanguageCode]="contentLanguage$ | async"
-            (languageCodeChange)="setLanguage($event)"
-        ></vdr-language-selector>
-    </div>
-    <vdr-data-table-2
-        class="mt-2"
-        id="product-list"
-        [items]="items$ | async"
-        [itemsPerPage]="itemsPerPage$ | async"
-        [totalItems]="totalItems$ | async"
-        [currentPage]="currentPage$ | async"
-        [filters]="filters"
-        (pageChange)="setPageNumber($event)"
-        (itemsPerPageChange)="setItemsPerPage($event)"
-    >
-        <vdr-bulk-action-menu
-            locationId="product-list"
-            [hostComponent]="this"
-            [selectionManager]="selectionManager"
-        ></vdr-bulk-action-menu>
-        <!--<ng-template #vdrDt2CustomSearch>
+<div class="flex wrap ml-4">
+    <vdr-language-selector
+        [availableLanguageCodes]="availableLanguages$ | async"
+        [currentLanguageCode]="contentLanguage$ | async"
+        (languageCodeChange)="setLanguage($event)"
+    ></vdr-language-selector>
+</div>
+<vdr-data-table-2
+    class="mt-2"
+    id="product-list"
+    [items]="items$ | async"
+    [itemsPerPage]="itemsPerPage$ | async"
+    [totalItems]="totalItems$ | async"
+    [currentPage]="currentPage$ | async"
+    [filters]="filters"
+    (pageChange)="setPageNumber($event)"
+    (itemsPerPageChange)="setItemsPerPage($event)"
+>
+    <vdr-bulk-action-menu
+        locationId="product-list"
+        [hostComponent]="this"
+        [selectionManager]="selectionManager"
+    ></vdr-bulk-action-menu>
+    <!--<ng-template #vdrDt2CustomSearch>
             <div class="search-form">
                 <vdr-product-search-input
                     #productSearchInputComponent
@@ -101,75 +74,74 @@
                 </vdr-dropdown>
             </div>
         </ng-template>-->
-        <vdr-dt2-search
-            [searchTermControl]="searchTermControl"
-            [searchTermPlaceholder]="'catalog.filter-by-name' | translate"
-        />
-        <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true" [sort]="sorts.get('id')">
-            <ng-template let-product="item">
-                {{ product.id }}
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column
-            [heading]="'common.created-at' | translate"
-            [hiddenByDefault]="true"
-            [sort]="sorts.get('createdAt')"
-        >
-            <ng-template let-product="item">
-                {{ product.createdAt | localeDate : 'short' }}
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column
-            [heading]="'common.updated-at' | translate"
-            [hiddenByDefault]="true"
-            [sort]="sorts.get('updatedAt')"
-        >
-            <ng-template let-product="item">
-                {{ product.updatedAt | localeDate : 'short' }}
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'common.image' | translate">
-            <ng-template let-product="item">
-                <div class="image-placeholder">
-                    <img
-                        *ngIf="product.featuredAsset as asset; else imagePlaceholder"
-                        [src]="asset | assetPreview : 'tiny'"
-                    />
-                    <ng-template #imagePlaceholder>
-                        <div class="placeholder">
-                            <clr-icon shape="image" size="48"></clr-icon>
-                        </div>
-                    </ng-template>
-                </div>
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'catalog.name' | translate" [optional]="false" [sort]="sorts.get('name')">
-            <ng-template let-product="item">
-                <a class="button-ghost" [routerLink]="['./', product.id]"
-                    ><span>{{ product.name }}</span
-                    ><clr-icon shape="arrow right"
-                /></a>
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'common.slug' | translate" [sort]="sorts.get('slug')">
-            <ng-template let-product="item">
-                {{ product.slug }}
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'common.enabled' | translate">
-            <ng-template let-product="item">
-                <vdr-chip *ngIf="product.enabled" colorType="success">{{
-                    'common.enabled' | translate
-                }}</vdr-chip>
-                <vdr-chip *ngIf="!product.enabled" colorType="warning">{{
-                    'common.disabled' | translate
-                }}</vdr-chip>
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'catalog.number-of-variants' | translate">
-            <ng-template let-product="item">
-                {{ 'catalog.variant-count' | translate : { count: product.variantList?.totalItems } }}
-            </ng-template>
-        </vdr-dt2-column>
-    </vdr-data-table-2>
-</vdr-page-body>
+    <vdr-dt2-search
+        [searchTermControl]="searchTermControl"
+        [searchTermPlaceholder]="'catalog.filter-by-name' | translate"
+    />
+    <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true" [sort]="sorts.get('id')">
+        <ng-template let-product="item">
+            {{ product.id }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'common.created-at' | translate"
+        [hiddenByDefault]="true"
+        [sort]="sorts.get('createdAt')"
+    >
+        <ng-template let-product="item">
+            {{ product.createdAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'common.updated-at' | translate"
+        [hiddenByDefault]="true"
+        [sort]="sorts.get('updatedAt')"
+    >
+        <ng-template let-product="item">
+            {{ product.updatedAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.image' | translate">
+        <ng-template let-product="item">
+            <div class="image-placeholder">
+                <img
+                    *ngIf="product.featuredAsset as asset; else imagePlaceholder"
+                    [src]="asset | assetPreview : 'tiny'"
+                />
+                <ng-template #imagePlaceholder>
+                    <div class="placeholder">
+                        <clr-icon shape="image" size="48"></clr-icon>
+                    </div>
+                </ng-template>
+            </div>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'catalog.name' | translate" [optional]="false" [sort]="sorts.get('name')">
+        <ng-template let-product="item">
+            <a class="button-ghost" [routerLink]="['./', product.id]"
+                ><span>{{ product.name }}</span
+                ><clr-icon shape="arrow right"
+            /></a>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.slug' | translate" [sort]="sorts.get('slug')">
+        <ng-template let-product="item">
+            {{ product.slug }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.enabled' | translate">
+        <ng-template let-product="item">
+            <vdr-chip *ngIf="product.enabled" colorType="success">{{
+                'common.enabled' | translate
+            }}</vdr-chip>
+            <vdr-chip *ngIf="!product.enabled" colorType="warning">{{
+                'common.disabled' | translate
+            }}</vdr-chip>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'catalog.number-of-variants' | translate">
+        <ng-template let-product="item">
+            {{ 'catalog.variant-count' | translate : { count: product.variantList?.totalItems } }}
+        </ng-template>
+    </vdr-dt2-column>
+</vdr-data-table-2>

+ 13 - 21
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts

@@ -1,9 +1,10 @@
-import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
+import { Component, OnInit, ViewChild } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     BaseListComponent,
     DataService,
+    DataTableService,
     GetProductListQuery,
     GetProductListQueryVariables,
     ItemOf,
@@ -11,19 +12,16 @@ import {
     JobState,
     LanguageCode,
     ModalService,
+    NavBuilderService,
     NotificationService,
     ProductFilterParameter,
     ProductSearchInputComponent,
     ProductSortParameter,
     SearchProductsQuery,
-    SelectionManager,
     ServerConfigService,
 } from '@vendure/admin-ui/core';
 import { EMPTY, Observable } from 'rxjs';
 import { delay, map, switchMap, takeUntil, tap } from 'rxjs/operators';
-import { DataTableService } from '../../../../core/src/providers/data-table/data-table.service';
-
-export type SearchItem = ItemOf<SearchProductsQuery, 'search'>;
 
 @Component({
     selector: 'vdr-products-list',
@@ -36,9 +34,8 @@ export class ProductListComponent
         ItemOf<GetProductListQuery, 'products'>,
         GetProductListQueryVariables
     >
-    implements OnInit, AfterViewInit
+    implements OnInit
 {
-    searchTerm = '';
     facetValueIds: string[] = [];
     groupByProduct = true;
     selectedFacetValueIds$: Observable<string[]>;
@@ -46,12 +43,6 @@ export class ProductListComponent
     availableLanguages$: Observable<LanguageCode[]>;
     contentLanguage$: Observable<LanguageCode>;
     pendingSearchIndexUpdates = 0;
-    selectionManager = new SelectionManager<SearchItem>({
-        multiSelect: true,
-        itemsAreEqual: (a, b) =>
-            this.groupByProduct ? a.productId === b.productId : a.productVariantId === b.productVariantId,
-        additiveMode: true,
-    });
     readonly filters = this.dataTableService
         .createFilterCollection<ProductFilterParameter>()
         .addDateFilters()
@@ -95,17 +86,25 @@ export class ProductListComponent
         private jobQueueService: JobQueueService,
         private serverConfigService: ServerConfigService,
         private dataTableService: DataTableService,
+        private navBuilderService: NavBuilderService,
         router: Router,
         route: ActivatedRoute,
     ) {
         super(router, route);
+        navBuilderService.addActionBarItem({
+            id: 'create-product',
+            label: _('catalog.create-new-product'),
+            locationId: 'product-list',
+            icon: 'plus',
+            routerLink: ['./create'],
+            requiresPermission: ['CreateCatalog', 'CreateProduct'],
+        });
         this.route.queryParamMap
             .pipe(
                 map(qpm => qpm.get('q')),
                 takeUntil(this.destroy$),
             )
             .subscribe(term => {
-                this.searchTerm = term || '';
                 if (this.productSearchInput) {
                     this.productSearchInput.setSearchTerm(term);
                 }
@@ -162,14 +161,7 @@ export class ProductListComponent
         super.refreshListOnChanges(this.contentLanguage$, this.filters.valueChanges, this.sorts.valueChanges);
     }
 
-    ngAfterViewInit() {
-        if (this.productSearchInput && this.searchTerm) {
-            setTimeout(() => this.productSearchInput.setSearchTerm(this.searchTerm));
-        }
-    }
-
     setSearchTerm(term: string) {
-        this.searchTerm = term;
         this.setQueryParam({ q: term || null, page: 1 });
         this.refresh();
     }

+ 68 - 0
packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-list-bulk-actions.ts

@@ -0,0 +1,68 @@
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import {
+    BulkAction,
+    DataService,
+    DeletionResult,
+    ModalService,
+    NotificationService,
+    Permission,
+} from '@vendure/admin-ui/core';
+import { unique } from '@vendure/common/lib/unique';
+import { EMPTY } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
+
+import { ProductVariantListComponent } from './product-variant-list.component';
+
+export const deleteProductVariantsBulkAction: BulkAction<any, ProductVariantListComponent> = {
+    location: 'product-list',
+    label: _('common.delete'),
+    icon: 'trash',
+    iconClass: 'is-danger',
+    requiresPermission: userPermissions =>
+        userPermissions.includes(Permission.DeleteProduct) ||
+        userPermissions.includes(Permission.DeleteCatalog),
+    onClick: ({ injector, selection, hostComponent, clearSelection }) => {
+        const modalService = injector.get(ModalService);
+        const dataService = injector.get(DataService);
+        const notificationService = injector.get(NotificationService);
+        modalService
+            .dialog({
+                title: _('catalog.confirm-bulk-delete-products'),
+                translationVars: {
+                    count: selection.length,
+                },
+                buttons: [
+                    { type: 'secondary', label: _('common.cancel') },
+                    { type: 'danger', label: _('common.delete'), returnValue: true },
+                ],
+            })
+            .pipe(
+                switchMap(response =>
+                    response
+                        ? dataService.product.deleteProducts(unique(selection.map(p => p.productId)))
+                        : EMPTY,
+                ),
+            )
+            .subscribe(result => {
+                let deleted = 0;
+                const errors: string[] = [];
+                for (const item of result.deleteProducts) {
+                    if (item.result === DeletionResult.DELETED) {
+                        deleted++;
+                    } else if (item.message) {
+                        errors.push(item.message);
+                    }
+                }
+                if (0 < deleted) {
+                    notificationService.success(_('catalog.notify-bulk-delete-products-success'), {
+                        count: deleted,
+                    });
+                }
+                if (0 < errors.length) {
+                    notificationService.error(errors.join('\n'));
+                }
+                hostComponent.refresh();
+                clearSelection();
+            });
+    },
+};

+ 119 - 0
packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.html

@@ -0,0 +1,119 @@
+<div class="flex wrap ml-4">
+    <vdr-language-selector
+        [availableLanguageCodes]="availableLanguages$ | async"
+        [currentLanguageCode]="contentLanguage$ | async"
+        (languageCodeChange)="setLanguage($event)"
+    ></vdr-language-selector>
+</div>
+<vdr-data-table-2
+    class="mt-2"
+    id="product-variant-list"
+    [items]="items$ | async"
+    [itemsPerPage]="itemsPerPage$ | async"
+    [totalItems]="totalItems$ | async"
+    [currentPage]="currentPage$ | async"
+    [filters]="filters"
+    (pageChange)="setPageNumber($event)"
+    (itemsPerPageChange)="setItemsPerPage($event)"
+>
+    <vdr-bulk-action-menu
+        locationId="product-variant-list"
+        [hostComponent]="this"
+        [selectionManager]="selectionManager"
+    />
+    <vdr-dt2-search
+        [searchTermControl]="searchTermControl"
+        [searchTermPlaceholder]="'catalog.filter-by-name' | translate"
+    />
+    <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true" [sort]="sorts.get('id')">
+        <ng-template let-variant="item">
+            {{ variant.id }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'common.created-at' | translate"
+        [hiddenByDefault]="true"
+        [sort]="sorts.get('createdAt')"
+    >
+        <ng-template let-variant="item">
+            {{ variant.createdAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'common.updated-at' | translate"
+        [hiddenByDefault]="true"
+        [sort]="sorts.get('updatedAt')"
+    >
+        <ng-template let-variant="item">
+            {{ variant.updatedAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.image' | translate">
+        <ng-template let-variant="item">
+            <div class="image-placeholder">
+                <img
+                    *ngIf="variant.featuredAsset as asset; else imagePlaceholder"
+                    [src]="asset | assetPreview : 'tiny'"
+                />
+                <ng-template #imagePlaceholder>
+                    <div class="placeholder">
+                        <clr-icon shape="image" size="48"></clr-icon>
+                    </div>
+                </ng-template>
+            </div>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'catalog.name' | translate" [optional]="false" [sort]="sorts.get('name')">
+        <ng-template let-variant="item">
+            <a class="button-ghost" [routerLink]="['./', variant.id]"
+                ><span>{{ variant.name }}</span
+                ><clr-icon shape="arrow right"
+            /></a>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'catalog.sku' | translate" [sort]="sorts.get('sku')">
+        <ng-template let-variant="item">
+            {{ variant.sku }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.enabled' | translate">
+        <ng-template let-variant="item">
+            <vdr-chip *ngIf="variant.enabled" colorType="success">{{
+                'common.enabled' | translate
+            }}</vdr-chip>
+            <vdr-chip *ngIf="!variant.enabled" colorType="warning">{{
+                'common.disabled' | translate
+            }}</vdr-chip>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'common.price' | translate"
+        [hiddenByDefault]="true"
+        [sort]="sorts.get('price')"
+    >
+        <ng-template let-variant="item">
+            {{ variant.price | localeCurrency : variant.currencyCode }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.price-with-tax' | translate" [sort]="sorts.get('priceWithTax')">
+        <ng-template let-variant="item">
+            {{ variant.priceWithTax | localeCurrency : variant.currencyCode }}
+        </ng-template>
+    </vdr-dt2-column>
+
+    <vdr-dt2-column [heading]="'catalog.stock-on-hand' | translate" [hiddenByDefault]="false">
+        <ng-template let-variant="item">
+            <vdr-chip *ngFor="let stockLevel of variant.stockLevels">
+                <div class="flex center">
+                  <!--  <clr-icon shape="map-marker" [title]="stockLevel.stockLocation.name"></clr-icon>-->
+                    <div>
+                        {{ stockLevel.stockOnHand
+                        }}<span class="ml-1" *ngIf="stockLevel.stockAllocated"
+                            >({{ stockLevel.stockAllocated }} allocated)</span
+                        >
+                    </div>
+                </div>
+            </vdr-chip>
+        </ng-template>
+    </vdr-dt2-column>
+</vdr-data-table-2>

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

@@ -0,0 +1,57 @@
+@import "variables";
+
+.image-col {
+    width: 70px;
+}
+.image-placeholder {
+    width: 50px;
+    height: 50px;
+    margin-top: calc(var(--space-unit) * -1);
+    margin-bottom: calc(var(--space-unit) * -1);
+    background-color: var(--color-component-bg-200);
+    img {
+        border-radius: var(--border-radius-img);
+    }
+    .placeholder {
+        text-align: center;
+        color: var(--color-grey-300);
+    }
+}
+.search-form {
+    display: flex;
+    align-items: center;
+    width: 100%;
+    //margin-bottom: 6px;
+}
+vdr-product-search-input {
+    min-width: 300px;
+    @media screen and (max-width: $breakpoint-small){
+        min-width: 100px;
+    }
+}
+.search-settings-menu {
+    margin: 0 12px;
+}
+td.disabled {
+    background-color: var(--color-component-bg-200);
+}
+.search-index-button {
+    position: relative;
+    vdr-status-badge {
+        right: 0;
+        top: 0;
+    }
+}
+.run-updates-button {
+    position: relative;
+    vdr-status-badge {
+        left: 10px;
+        top: 10px;
+    }
+}
+.edit-button {
+    margin-right: 24px;
+}
+.sku {
+    color: var(--color-text-300);
+}

+ 133 - 0
packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.ts

@@ -0,0 +1,133 @@
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import {
+    BaseListComponent,
+    DataService,
+    GetProductVariantListQuery,
+    GetProductVariantListQueryVariables,
+    ItemOf,
+    JobQueueService,
+    LanguageCode,
+    ModalService,
+    NavBuilderService,
+    NotificationService,
+    ProductFilterParameter,
+    ProductSortParameter,
+    ProductVariantFilterParameter,
+    ProductVariantSortParameter,
+    ServerConfigService,
+} from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+import { tap } from 'rxjs/operators';
+import { DataTableService } from '../../../../core/src/providers/data-table/data-table.service';
+
+@Component({
+    selector: 'vdr-product-variant-list',
+    templateUrl: './product-variant-list.component.html',
+    styleUrls: ['./product-variant-list.component.scss'],
+})
+export class ProductVariantListComponent
+    extends BaseListComponent<
+        GetProductVariantListQuery,
+        ItemOf<GetProductVariantListQuery, 'productVariants'>,
+        GetProductVariantListQueryVariables
+    >
+    implements OnInit
+{
+    availableLanguages$: Observable<LanguageCode[]>;
+    contentLanguage$: Observable<LanguageCode>;
+    readonly filters = this.dataTableService
+        .createFilterCollection<ProductVariantFilterParameter>()
+        .addDateFilters()
+        .addFilter({
+            name: 'id',
+            type: { kind: 'text' },
+            label: _('common.id'),
+            filterField: 'id',
+        })
+        .addFilter({
+            name: 'enabled',
+            type: { kind: 'boolean' },
+            label: _('common.enabled'),
+            filterField: 'enabled',
+        })
+        .addFilter({
+            name: 'sku',
+            type: { kind: 'text' },
+            label: _('catalog.sku'),
+            filterField: 'sku',
+        })
+        .addFilter({
+            name: 'price',
+            type: { kind: 'number', inputType: 'currency' },
+            label: _('common.price'),
+            filterField: 'price',
+        })
+        .addFilter({
+            name: 'priceWithTax',
+            type: { kind: 'number', inputType: 'currency' },
+            label: _('common.price-with-tax'),
+            filterField: 'priceWithTax',
+        })
+        .connectToRoute(this.route);
+
+    readonly sorts = this.dataTableService
+        .createSortCollection<ProductVariantSortParameter>()
+        .defaultSort('createdAt', 'DESC')
+        .addSort({ name: 'id' })
+        .addSort({ name: 'createdAt' })
+        .addSort({ name: 'updatedAt' })
+        .addSort({ name: 'name' })
+        .addSort({ name: 'sku' })
+        .addSort({ name: 'price' })
+        .addSort({ name: 'priceWithTax' })
+        .connectToRoute(this.route);
+
+    constructor(
+        private dataService: DataService,
+        private modalService: ModalService,
+        private notificationService: NotificationService,
+        private jobQueueService: JobQueueService,
+        private serverConfigService: ServerConfigService,
+        private dataTableService: DataTableService,
+        private navBuilderService: NavBuilderService,
+        router: Router,
+        route: ActivatedRoute,
+    ) {
+        super(router, route);
+        super.setQueryFn(
+            (args: any) => this.dataService.product.getProductVariants(args).refetchOnChannelChange(),
+            data => data.productVariants,
+            // eslint-disable-next-line @typescript-eslint/no-shadow
+            (skip, take) => ({
+                options: {
+                    skip,
+                    take,
+                    filter: {
+                        name: {
+                            contains: this.searchTermControl.value,
+                        },
+                        ...this.filters.createFilterInput(),
+                    },
+                    sort: this.sorts.createSortInput(),
+                },
+            }),
+        );
+    }
+
+    ngOnInit() {
+        super.ngOnInit();
+
+        this.availableLanguages$ = this.serverConfigService.getAvailableLanguages();
+        this.contentLanguage$ = this.dataService.client
+            .uiState()
+            .mapStream(({ uiState }) => uiState.contentLanguage)
+            .pipe(tap(() => this.refresh()));
+        super.refreshListOnChanges(this.contentLanguage$, this.filters.valueChanges, this.sorts.valueChanges);
+    }
+
+    setLanguage(code: LanguageCode) {
+        this.dataService.client.setContentLanguage(code).subscribe();
+    }
+}

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

@@ -10,7 +10,9 @@ export * from './components/assign-to-channel-dialog/assign-to-channel-dialog.co
 export * from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 export * from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.graphql';
 export * from './components/collection-contents/collection-contents.component';
+export * from './components/collection-data-table/collection-data-table.component';
 export * from './components/collection-detail/collection-detail.component';
+export * from './components/collection-list/collection-breadcrumb.pipe';
 export * from './components/collection-list/collection-list-bulk-actions';
 export * from './components/collection-list/collection-list.component';
 export * from './components/collection-tree/array-to-tree';
@@ -23,12 +25,15 @@ export * from './components/facet-detail/facet-detail.component';
 export * from './components/facet-list/facet-list-bulk-actions';
 export * from './components/facet-list/facet-list.component';
 export * from './components/generate-product-variants/generate-product-variants.component';
+export * from './components/move-collections-dialog/move-collections-dialog.component';
 export * from './components/option-value-input/option-value-input.component';
 export * from './components/product-detail/product-detail.component';
 export * from './components/product-detail/product-detail.types';
 export * from './components/product-list/product-list-bulk-actions';
 export * from './components/product-list/product-list.component';
 export * from './components/product-options-editor/product-options-editor.component';
+export * from './components/product-variant-list/product-list-bulk-actions';
+export * from './components/product-variant-list/product-variant-list.component';
 export * from './components/product-variants-editor/product-variants-editor.component';
 export * from './components/product-variants-list/product-variants-list.component';
 export * from './components/product-variants-table/product-variants-table.component';

+ 9 - 7
packages/admin-ui/src/lib/core/src/common/component-registry-types.ts

@@ -42,13 +42,7 @@ export type InputComponentConfig = {
     [prop: string]: any;
 };
 
-/**
- * @description
- * The valid locationIds for registering action bar items.
- *
- * @docsCategory action-bar
- */
-export type ActionBarLocationId =
+export type PageLocationId =
     | 'administrator-detail'
     | 'administrator-list'
     | 'asset-detail'
@@ -87,6 +81,14 @@ export type ActionBarLocationId =
     | 'tax-rate-list'
     | 'zone-list';
 
+/**
+ * @description
+ * The valid locationIds for registering action bar items.
+ *
+ * @docsCategory action-bar
+ */
+export type ActionBarLocationId = PageLocationId;
+
 /**
  * @description
  * The valid locations for embedding a {@link CustomDetailComponent}.

+ 31 - 2
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -766,12 +766,41 @@ export const GET_PRODUCT_VARIANT_LIST = gql`
     query GetProductVariantList($options: ProductVariantListOptions!, $productId: ID) {
         productVariants(options: $options, productId: $productId) {
             items {
-                ...ProductVariant
+                id
+                createdAt
+                updatedAt
+                enabled
+                languageCode
+                name
+                price
+                currencyCode
+                priceWithTax
+                trackInventory
+                outOfStockThreshold
+                stockLevels {
+                    id
+                    createdAt
+                    updatedAt
+                    stockLocationId
+                    stockOnHand
+                    stockAllocated
+                    stockLocation {
+                        id
+                        createdAt
+                        updatedAt
+                        name
+                    }
+                }
+                useGlobalOutOfStockThreshold
+                sku
+                featuredAsset {
+                    ...Asset
+                }
             }
             totalItems
         }
     }
-    ${PRODUCT_VARIANT_FRAGMENT}
+    ${ASSET_FRAGMENT}
 `;
 
 export const GET_TAG_LIST = gql`

+ 1 - 1
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts

@@ -96,7 +96,7 @@ export interface ActionBarItem {
     buttonColor?: 'primary' | 'success' | 'warning';
     buttonStyle?: 'solid' | 'outline' | 'link';
     icon?: string;
-    requiresPermission?: string;
+    requiresPermission?: string | string[];
 }
 
 export type RouterLinkDefinition = ((route: ActivatedRoute) => any[]) | any[];

+ 6 - 4
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.ts

@@ -191,10 +191,12 @@ export class NavBuilderService {
      * `data-location-id` attribute.
      */
     addActionBarItem(config: ActionBarItem) {
-        this.addedActionBarItems.push({
-            requiresPermission: Permission.Authenticated,
-            ...config,
-        });
+        if (!this.addedActionBarItems.find(item => item.id === config.id)) {
+            this.addedActionBarItems.push({
+                requiresPermission: Permission.Authenticated,
+                ...config,
+            });
+        }
     }
 
     getRouterLink(config: { routerLink?: RouterLinkDefinition }, route: ActivatedRoute): string[] | null {

+ 43 - 0
packages/admin-ui/src/lib/core/src/providers/page/page.service.ts

@@ -0,0 +1,43 @@
+import { Injectable, Type } from '@angular/core';
+import { Route } from '@angular/router';
+import { PageLocationId } from '../../common/component-registry-types';
+
+export interface PageTabConfig {
+    location: PageLocationId;
+    tabIcon?: string;
+    route: string;
+    tab: string;
+    component: Type<any>;
+}
+
+@Injectable({
+    providedIn: 'root',
+})
+export class PageService {
+    private registry = new Map<PageLocationId, PageTabConfig[]>();
+
+    registerPageTab(config: PageTabConfig) {
+        if (!this.registry.has(config.location)) {
+            this.registry.set(config.location, []);
+        }
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        const pages = this.registry.get(config.location)!;
+        if (pages.find(p => p.tab === config.tab)) {
+            throw new Error(`A page with the tab "${config.tab}" has already been registered`);
+        }
+        pages.push(config);
+    }
+
+    getPageTabRoutes(location: PageLocationId): Route[] {
+        const configs = this.registry.get(location) || [];
+        return configs.map(config => ({
+            path: config.route || '',
+            pathMatch: config.route ? 'prefix' : 'full',
+            component: config.component,
+        }));
+    }
+
+    getPageTabs(location: PageLocationId): PageTabConfig[] {
+        return this.registry.get(location) || [];
+    }
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -104,6 +104,7 @@ export * from './providers/nav-builder/nav-builder-types';
 export * from './providers/nav-builder/nav-builder.service';
 export * from './providers/notification/notification.service';
 export * from './providers/overlay-host/overlay-host.service';
+export * from './providers/page/page.service';
 export * from './shared/components/action-bar/action-bar.component';
 export * from './shared/components/action-bar-items/action-bar-items.component';
 export * from './shared/components/address-form/address-form.component';
@@ -168,6 +169,7 @@ export * from './shared/components/modal-dialog/dialog-title.directive';
 export * from './shared/components/modal-dialog/modal-dialog.component';
 export * from './shared/components/object-tree/object-tree.component';
 export * from './shared/components/order-state-label/order-state-label.component';
+export * from './shared/components/page/page.component';
 export * from './shared/components/page-body/page-body.component';
 export * from './shared/components/page-header/page-header.component';
 export * from './shared/components/page-header-description/page-header-description.component';
@@ -202,6 +204,8 @@ export * from './shared/components/rich-text-editor/raw-html-dialog/raw-html-dia
 export * from './shared/components/rich-text-editor/rich-text-editor.component';
 export * from './shared/components/select-toggle/select-toggle.component';
 export * from './shared/components/simple-dialog/simple-dialog.component';
+export * from './shared/components/split-view/split-view.component';
+export * from './shared/components/split-view/split-view.directive';
 export * from './shared/components/status-badge/status-badge.component';
 export * from './shared/components/tabbed-custom-fields/tabbed-custom-fields.component';
 export * from './shared/components/table-row-action/table-row-action.component';

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

@@ -140,7 +140,11 @@ tr:last-of-type td:last-of-type {
 
 // odd rows
 tbody tr:nth-child(even) {
-    background-color: var(--color-table-alternate-row-bg);
+    //background-color: var(--color-table-alternate-row-bg);
+}
+
+tbody td {
+    border-bottom: 1px solid var(--color-weight-100);
 }
 
 tbody tr:hover {

+ 8 - 2
packages/admin-ui/src/lib/core/src/shared/components/page-header-tabs/page-header-tabs.component.html

@@ -1,5 +1,11 @@
 <div class="tab-bar" *ngIf="tabs.length">
-    <div class="tab" *ngFor="let tab of tabs" [class.active]="selectedTabId === tab.id">
+    <a
+        [routerLink]="tab.route"
+        class="tab"
+        *ngFor="let tab of tabs"
+        routerLinkActive="active"
+        [routerLinkActiveOptions]="{ exact: true }"
+    >
         {{ tab.label | translate }}
-    </div>
+    </a>
 </div>

+ 2 - 9
packages/admin-ui/src/lib/core/src/shared/components/page-header-tabs/page-header-tabs.component.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
 
 export interface HeaderTab {
     id: string;
@@ -13,13 +13,6 @@ export interface HeaderTab {
     styleUrls: ['./page-header-tabs.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class PageHeaderTabsComponent implements OnChanges {
+export class PageHeaderTabsComponent {
     @Input() tabs: HeaderTab[] = [];
-    @Input() selectedTabId: string | undefined;
-
-    ngOnChanges(changes: SimpleChanges) {
-        if (this.tabs.length && !this.selectedTabId) {
-            this.selectedTabId = this.tabs[0]?.id;
-        }
-    }
 }

+ 10 - 0
packages/admin-ui/src/lib/core/src/shared/components/page/page.component.html

@@ -0,0 +1,10 @@
+<vdr-page-header>
+    <vdr-page-title>
+        <vdr-action-bar-items [locationId]="locationId"></vdr-action-bar-items>
+    </vdr-page-title>
+    <vdr-page-header-description *ngIf="description">{{ description }}</vdr-page-header-description>
+    <vdr-page-header-tabs *ngIf="headerTabs.length > 1" [tabs]="headerTabs"></vdr-page-header-tabs>
+</vdr-page-header>
+<vdr-page-body>
+    <router-outlet />
+</vdr-page-body>

+ 0 - 0
packages/admin-ui/src/lib/core/src/shared/components/page/page.component.scss


+ 27 - 0
packages/admin-ui/src/lib/core/src/shared/components/page/page.component.ts

@@ -0,0 +1,27 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { PageLocationId } from '../../../common/component-registry-types';
+import { HeaderTab } from '../page-header-tabs/page-header-tabs.component';
+import { PageService } from '../../../providers/page/page.service';
+
+@Component({
+    selector: 'vdr-page',
+    templateUrl: './page.component.html',
+    styleUrls: ['./page.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PageComponent {
+    headerTabs: HeaderTab[] = [];
+    @Input() protected locationId: PageLocationId;
+    @Input() protected description: string;
+    constructor(private route: ActivatedRoute, private pageService: PageService) {
+        this.locationId = this.route.snapshot.data.locationId;
+        this.description = this.route.snapshot.data.description ?? '';
+        this.headerTabs = this.pageService.getPageTabs(this.locationId).map(tab => ({
+            id: tab.tab,
+            label: tab.tab,
+            icon: tab.tabIcon,
+            route: tab.route ? [tab.route] : ['./'],
+        }));
+    }
+}

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -156,6 +156,7 @@ import { StateI18nTokenPipe } from './pipes/state-i18n-token.pipe';
 import { StringToColorPipe } from './pipes/string-to-color.pipe';
 import { TimeAgoPipe } from './pipes/time-ago.pipe';
 import { CanDeactivateDetailGuard } from './providers/routing/can-deactivate-detail-guard';
+import { PageComponent } from './components/page/page.component';
 
 const IMPORTS = [
     ClarityModule,
@@ -278,6 +279,7 @@ const DECLARATIONS = [
     SplitViewComponent,
     SplitViewLeftDirective,
     SplitViewRightDirective,
+    PageComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [

+ 3 - 0
packages/admin-ui/src/lib/customer/src/public_api.ts

@@ -4,10 +4,13 @@ export * from './components/address-card/address-card.component';
 export * from './components/address-detail-dialog/address-detail-dialog.component';
 export * from './components/customer-detail/customer-detail.component';
 export * from './components/customer-group-detail-dialog/customer-group-detail-dialog.component';
+export * from './components/customer-group-list/customer-group-list-bulk-actions';
 export * from './components/customer-group-list/customer-group-list.component';
+export * from './components/customer-group-member-list/customer-group-member-list-bulk-actions';
 export * from './components/customer-group-member-list/customer-group-member-list.component';
 export * from './components/customer-history/customer-history-entry-host.component';
 export * from './components/customer-history/customer-history.component';
+export * from './components/customer-list/customer-list-bulk-actions';
 export * from './components/customer-list/customer-list.component';
 export * from './components/customer-status-label/customer-status-label.component';
 export * from './components/select-customer-group-dialog/select-customer-group-dialog.component';

+ 1 - 0
packages/admin-ui/src/lib/marketing/src/public_api.ts

@@ -1,5 +1,6 @@
 // This file was generated by the build-public-api.ts script
 export * from './components/promotion-detail/promotion-detail.component';
+export * from './components/promotion-list/promotion-list-bulk-actions';
 export * from './components/promotion-list/promotion-list.component';
 export * from './marketing.module';
 export * from './marketing.routes';

+ 11 - 0
packages/admin-ui/src/lib/settings/src/public_api.ts

@@ -1,33 +1,44 @@
 // This file was generated by the build-public-api.ts script
 export * from './components/add-country-to-zone-dialog/add-country-to-zone-dialog.component';
 export * from './components/admin-detail/admin-detail.component';
+export * from './components/administrator-list/administrator-list-bulk-actions';
 export * from './components/administrator-list/administrator-list.component';
 export * from './components/channel-detail/channel-detail.component';
+export * from './components/channel-list/channel-list-bulk-actions';
 export * from './components/channel-list/channel-list.component';
 export * from './components/country-detail/country-detail.component';
+export * from './components/country-list/country-list-bulk-actions';
 export * from './components/country-list/country-list.component';
 export * from './components/global-settings/global-settings.component';
 export * from './components/payment-method-detail/payment-method-detail.component';
+export * from './components/payment-method-list/payment-method-list-bulk-actions';
 export * from './components/payment-method-list/payment-method-list.component';
 export * from './components/permission-grid/permission-grid.component';
 export * from './components/profile/profile.component';
 export * from './components/role-detail/role-detail.component';
+export * from './components/role-list/role-list-bulk-actions';
 export * from './components/role-list/role-list.component';
 export * from './components/seller-detail/seller-detail.component';
+export * from './components/seller-list/seller-list-bulk-actions';
 export * from './components/seller-list/seller-list.component';
 export * from './components/shipping-eligibility-test-result/shipping-eligibility-test-result.component';
 export * from './components/shipping-method-detail/shipping-method-detail.component';
+export * from './components/shipping-method-list/shipping-method-list-bulk-actions';
 export * from './components/shipping-method-list/shipping-method-list.component';
 export * from './components/shipping-method-test-result/shipping-method-test-result.component';
 export * from './components/tax-category-detail/tax-category-detail.component';
+export * from './components/tax-category-list/tax-category-list-bulk-actions';
 export * from './components/tax-category-list/tax-category-list.component';
 export * from './components/tax-rate-detail/tax-rate-detail.component';
+export * from './components/tax-rate-list/tax-rate-list-bulk-actions';
 export * from './components/tax-rate-list/tax-rate-list.component';
 export * from './components/test-address-form/test-address-form.component';
 export * from './components/test-order-builder/test-order-builder.component';
 export * from './components/zone-detail-dialog/zone-detail-dialog.component';
+export * from './components/zone-list/zone-list-bulk-actions';
 export * from './components/zone-list/zone-list.component';
 export * from './components/zone-member-list/zone-member-controls.directive';
+export * from './components/zone-member-list/zone-member-list-bulk-actions';
 export * from './components/zone-member-list/zone-member-list-header.directive';
 export * from './components/zone-member-list/zone-member-list.component';
 export * from './providers/routing/administrator-resolver';

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

@@ -142,6 +142,7 @@
     "product-details": "Detaily produktu",
     "product-name": "Jméno produktu",
     "product-variants": "Varianty produktu",
+    "products": "",
     "public": "Veřejný",
     "reindex-error": "Při regeneraci vyhledávacího indexu došlo k chybě",
     "reindex-successful": "Zaindexováno: {count, plural, one {varianta produktu} other {{count} variant produktu}} během {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Produktdetails",
     "product-name": "Produktname",
     "product-variants": "Produktvarianten",
+    "products": "",
     "public": "Öffentlich",
     "reindex-error": "Beim Neuaufbau des Suchindex ist ein Fehler aufgetreten",
     "reindex-successful": "{count, plural, one {Produktvariante} other {{count} Produktvarianten}} indiziert in {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Product details",
     "product-name": "Product name",
     "product-variants": "Product variants",
+    "products": "Products",
     "public": "Public",
     "reindex-error": "An error occurred while rebuilding search index",
     "reindex-successful": "Indexed {count, plural, one {product variant} other {{count} product variants}} in {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Detalles de producto",
     "product-name": "Nombre del producto",
     "product-variants": "Variantes de producto",
+    "products": "",
     "public": "Público",
     "reindex-error": "Ha ocurrido un error reconstruyendo el índice de búsqueda",
     "reindex-successful": "Indexado {count, plural, one {variante} other {{count} variantes}} en {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Détails du produit",
     "product-name": "Nom du produit",
     "product-variants": "Variations du produit",
+    "products": "",
     "public": "Public",
     "reindex-error": "Une erreur s'est produite lors de la reconstruction de l'index de recherche",
     "reindex-successful": "Indexation {count, plural, one {d'une variation de produit} other {de {count} variations de produit}} en {time} ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Dettagli prodotto",
     "product-name": "Nome del Prodotto",
     "product-variants": "Varianti del Prodotto",
+    "products": "",
     "public": "Pubblico",
     "reindex-error": "Si è verificato un errore nella ricostruzione dell'indice di ricerca",
     "reindex-successful": "{count, plural, one {Indicizzata una variante prodotto} other {Indicizzate {count} varianti prodotto}} in {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Szczegóły produktu",
     "product-name": "Nazwa produktu",
     "product-variants": "Warianty produktu",
+    "products": "",
     "public": "",
     "reindex-error": "Wystąpił błąd podczas przebudowania indeksów",
     "reindex-successful": "Zaindeksowano {count, plural, one {wariant produktu} other {{count} wariantów produktu}} w {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Detalhes do produto",
     "product-name": "Nome do produto",
     "product-variants": "Variações do produto",
+    "products": "",
     "public": "Público",
     "reindex-error": "Ocorreu um erro ao recriar o índice de pesquisa",
     "reindex-successful": "Indexado {count, plural, one {product variant} other {{count} product variants}} em {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Detalhes do produto",
     "product-name": "Nome do produto",
     "product-variants": "Variações do produto",
+    "products": "",
     "public": "Público",
     "reindex-error": "Ocorreu um erro ao reconstruir o índice de pesquisa",
     "reindex-successful": "{count, plural, one {Variante do produto indexada} other {{count} variantes de produtos indexadas}} em {time}ms",

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

@@ -142,6 +142,7 @@
     "product-details": "Информация о товаре",
     "product-name": "Имя товара",
     "product-variants": "Вариант товара",
+    "products": "",
     "public": "Публичный",
     "reindex-error": "Произошла ошибка при перестройке индекса поиска",
     "reindex-successful": "Проиндексировано {count, plural, one {вариант товара} other {{count} вариантов товара}} за {time}мс",

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

@@ -142,6 +142,7 @@
     "product-details": "Інформація про товар",
     "product-name": "Ім'я товару",
     "product-variants": "Варіант товару",
+    "products": "",
     "public": "Публічний",
     "reindex-error": "Помилка при перебудові індексу пошуку",
     "reindex-successful": "Проіндексовано {count, plural, one {варіант товару} other {{count} варіантів товару}} за {time}мс",

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

@@ -142,6 +142,7 @@
     "product-details": "商品详情",
     "product-name": "商品名称",
     "product-variants": "商品规格",
+    "products": "",
     "public": "公开",
     "reindex-error": "重建索引失败",
     "reindex-successful": "已成功重建{count}个产品索引,耗时{time}毫秒",

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

@@ -142,6 +142,7 @@
     "product-details": "商品詳情",
     "product-name": "商品名稱",
     "product-variants": "商品規格",
+    "products": "",
     "public": "公開",
     "reindex-error": "重建索引失敗",
     "reindex-successful": "已成功重建{count}個產品索引,耗时{time}毫秒",