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

feat(admin-ui): Implement all list/detail views with new format

Michael Bromley 2 лет назад
Родитель
Сommit
f3b5fa873d
100 измененных файлов с 3131 добавлено и 4844 удалено
  1. 64 34
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  2. 9 60
      packages/admin-ui/src/lib/catalog/src/catalog.routes.ts
  3. 25 23
      packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.html
  4. 35 23
      packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.ts
  5. 37 29
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.html
  6. 84 80
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts
  7. 10 0
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html
  8. 8 0
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts
  9. 173 155
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.html
  10. 99 81
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts
  11. 20 5
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.html
  12. 28 51
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts
  13. 173 269
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  14. 1 18
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  15. 87 329
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  16. 0 30
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.types.ts
  17. 0 189
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.html
  18. 0 64
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.scss
  19. 0 461
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.ts
  20. 7 3
      packages/admin-ui/src/lib/catalog/src/components/product-variant-detail/product-variant-detail.component.ts
  21. 0 296
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html
  22. 0 168
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss
  23. 0 280
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  24. 11 2
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts
  25. 3 2
      packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts
  26. 0 30
      packages/admin-ui/src/lib/catalog/src/providers/routing/asset-resolver.ts
  27. 0 39
      packages/admin-ui/src/lib/catalog/src/providers/routing/collection-resolver.ts
  28. 0 32
      packages/admin-ui/src/lib/catalog/src/providers/routing/facet-resolver.ts
  29. 0 6
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  30. 29 9
      packages/admin-ui/src/lib/core/src/common/base-detail.component.ts
  31. 5 1
      packages/admin-ui/src/lib/core/src/common/component-registry-types.ts
  32. 123 149
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  33. 2 2
      packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts
  34. 1 1
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html
  35. 0 30
      packages/admin-ui/src/lib/core/src/data/definitions/administrator-definitions.ts
  36. 0 9
      packages/admin-ui/src/lib/core/src/data/definitions/collection-definitions.ts
  37. 3 24
      packages/admin-ui/src/lib/core/src/data/definitions/customer-definitions.ts
  38. 0 21
      packages/admin-ui/src/lib/core/src/data/definitions/facet-definitions.ts
  39. 0 21
      packages/admin-ui/src/lib/core/src/data/definitions/promotion-definitions.ts
  40. 0 115
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  41. 0 21
      packages/admin-ui/src/lib/core/src/data/definitions/shipping-definitions.ts
  42. 0 32
      packages/admin-ui/src/lib/core/src/data/providers/administrator-data.service.ts
  43. 0 10
      packages/admin-ui/src/lib/core/src/data/providers/collection-data.service.ts
  44. 5 16
      packages/admin-ui/src/lib/core/src/data/providers/customer-data.service.ts
  45. 14 42
      packages/admin-ui/src/lib/core/src/data/providers/facet-data.service.ts
  46. 0 24
      packages/admin-ui/src/lib/core/src/data/providers/promotion-data.service.ts
  47. 10 119
      packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts
  48. 0 23
      packages/admin-ui/src/lib/core/src/data/providers/shipping-method-data.service.ts
  49. 1 1
      packages/admin-ui/src/lib/core/src/providers/page/page.service.ts
  50. 15 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts
  51. 2 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview-links/asset-preview-links.component.scss
  52. 133 131
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html
  53. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.scss
  54. 8 7
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts
  55. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/chip/chip.component.scss
  56. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.scss
  57. 4 0
      packages/admin-ui/src/lib/core/src/shared/components/form-field/form-field.component.scss
  58. 3 1
      packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.scss
  59. 11 0
      packages/admin-ui/src/lib/core/src/shared/components/zone-selector/zone-selector.component.html
  60. 7 0
      packages/admin-ui/src/lib/core/src/shared/components/zone-selector/zone-selector.component.scss
  61. 102 0
      packages/admin-ui/src/lib/core/src/shared/components/zone-selector/zone-selector.component.ts
  62. 15 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts
  63. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  64. 227 171
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html
  65. 123 84
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts
  66. 62 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail/customer-group-detail.component.html
  67. 0 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail/customer-group-detail.component.scss
  68. 119 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail/customer-group-detail.component.ts
  69. 105 105
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html
  70. 34 75
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.ts
  71. 0 1
      packages/admin-ui/src/lib/customer/src/components/customer-history/customer-history.component.html
  72. 73 79
      packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.html
  73. 38 33
      packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.ts
  74. 73 5
      packages/admin-ui/src/lib/customer/src/customer.module.ts
  75. 21 8
      packages/admin-ui/src/lib/customer/src/customer.routes.ts
  76. 0 31
      packages/admin-ui/src/lib/customer/src/providers/routing/customer-resolver.ts
  77. 0 1
      packages/admin-ui/src/lib/customer/src/public_api.ts
  78. 165 148
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html
  79. 75 63
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.ts
  80. 19 1
      packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.html
  81. 79 94
      packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.ts
  82. 25 2
      packages/admin-ui/src/lib/marketing/src/marketing.module.ts
  83. 5 16
      packages/admin-ui/src/lib/marketing/src/marketing.routes.ts
  84. 0 31
      packages/admin-ui/src/lib/marketing/src/providers/routing/promotion-resolver.ts
  85. 0 1
      packages/admin-ui/src/lib/marketing/src/public_api.ts
  86. 28 3
      packages/admin-ui/src/lib/order/src/components/add-manual-payment-dialog/add-manual-payment-dialog.component.ts
  87. 29 4
      packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts
  88. 170 149
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html
  89. 14 1
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts
  90. 0 1
      packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html
  91. 1 4
      packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.scss
  92. 5 0
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html
  93. 1 0
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts
  94. 22 1
      packages/admin-ui/src/lib/order/src/order.module.ts
  95. 5 6
      packages/admin-ui/src/lib/order/src/order.routes.ts
  96. 46 7
      packages/admin-ui/src/lib/settings/src/components/add-country-to-zone-dialog/add-country-to-zone-dialog.component.ts
  97. 126 97
      packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.html
  98. 1 0
      packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.scss
  99. 58 49
      packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.ts
  100. 7 2
      packages/admin-ui/src/lib/settings/src/components/administrator-list/administrator-list-bulk-actions.ts

+ 64 - 34
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -1,9 +1,13 @@
-import { Component, NgModule } from '@angular/core';
+import { NgModule } from '@angular/core';
 import { RouterModule, ROUTES } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    AssetDetailQueryDocument,
     BulkActionRegistryService,
+    CollectionDetailQueryDocument,
     detailComponentWithResolver,
+    GetFacetDetailDocument,
+    GetProductDetailDocument,
     PageService,
     ProductVariantDetailQueryDocument,
     SharedModule,
@@ -42,7 +46,6 @@ import { GenerateProductVariantsComponent } from './components/generate-product-
 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 { ProductDetail2Component } from './components/product-detail2/product-detail.component';
 import {
     assignFacetValuesToProductsBulkAction,
     assignProductsToChannelBulkAction,
@@ -54,7 +57,6 @@ import { ProductOptionsEditorComponent } from './components/product-options-edit
 import { ProductVariantDetailComponent } from './components/product-variant-detail/product-variant-detail.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';
@@ -65,7 +67,6 @@ const CATALOG_COMPONENTS = [
     FacetListComponent,
     FacetDetailComponent,
     GenerateProductVariantsComponent,
-    ProductVariantsListComponent,
     ApplyFacetDialogComponent,
     AssetListComponent,
     AssetsComponent,
@@ -89,7 +90,7 @@ const CATALOG_COMPONENTS = [
     CollectionBreadcrumbPipe,
     MoveCollectionsDialogComponent,
     ProductVariantListComponent,
-    ProductDetail2Component,
+    ProductDetailComponent,
     ProductVariantDetailComponent,
 ];
 
@@ -135,7 +136,17 @@ export class CatalogModule {
             location: 'product-detail',
             tab: _('catalog.products'),
             route: '',
-            component: ProductDetail2Component,
+            component: detailComponentWithResolver({
+                component: ProductDetailComponent,
+                query: GetProductDetailDocument,
+                entityKey: 'product',
+                getBreadcrumbs: entity => [
+                    {
+                        label: entity ? entity.name : _('catalog.create-new-product'),
+                        link: [entity?.id],
+                    },
+                ],
+            }),
         });
         pageService.registerPageTab({
             location: 'product-list',
@@ -150,19 +161,15 @@ export class CatalogModule {
             component: detailComponentWithResolver({
                 component: ProductVariantDetailComponent,
                 query: ProductVariantDetailQueryDocument,
-                getEntity: result => result.productVariant,
-                getBreadcrumbs: result => [
-                    {
-                        label: _('breadcrumb.products'),
-                        link: ['/catalog', 'products'],
-                    },
+                entityKey: 'productVariant',
+                getBreadcrumbs: entity => [
                     {
-                        label: `${result.productVariant?.product.name}`,
-                        link: ['/catalog', 'products', result.productVariant?.product.id],
+                        label: `${entity?.product.name}`,
+                        link: ['/catalog', 'products', entity?.product.id],
                     },
                     {
-                        label: `${result.productVariant?.name}`,
-                        link: ['variants', result.productVariant?.id],
+                        label: `${entity?.name}`,
+                        link: ['variants', entity?.id],
                     },
                 ],
             }),
@@ -173,6 +180,22 @@ export class CatalogModule {
             route: '',
             component: FacetListComponent,
         });
+        pageService.registerPageTab({
+            location: 'facet-detail',
+            tab: _('catalog.facet'),
+            route: '',
+            component: detailComponentWithResolver({
+                component: FacetDetailComponent,
+                query: GetFacetDetailDocument,
+                entityKey: 'facet',
+                getBreadcrumbs: entity => [
+                    {
+                        label: entity ? entity.name : _('catalog.create-new-facet'),
+                        link: [entity?.id],
+                    },
+                ],
+            }),
+        });
         pageService.registerPageTab({
             location: 'collection-list',
             tab: _('catalog.collections'),
@@ -183,7 +206,17 @@ export class CatalogModule {
             location: 'collection-detail',
             tab: _('catalog.collection'),
             route: '',
-            component: CollectionDetailComponent,
+            component: detailComponentWithResolver({
+                component: CollectionDetailComponent,
+                query: CollectionDetailQueryDocument,
+                entityKey: 'collection',
+                getBreadcrumbs: entity => [
+                    {
+                        label: entity ? entity.name : _('catalog.create-new-collection'),
+                        link: [entity?.id],
+                    },
+                ],
+            }),
         });
         pageService.registerPageTab({
             location: 'asset-list',
@@ -191,24 +224,21 @@ export class CatalogModule {
             route: '',
             component: AssetListComponent,
         });
-
         pageService.registerPageTab({
-            location: 'product-list',
-            tab: 'Stock control',
-            route: 'stock-control',
-            component: StockControlComponent,
+            location: 'asset-detail',
+            tab: _('catalog.asset'),
+            route: '',
+            component: detailComponentWithResolver({
+                component: AssetDetailComponent,
+                query: AssetDetailQueryDocument,
+                entityKey: 'asset',
+                getBreadcrumbs: entity => [
+                    {
+                        label: `${entity?.name}`,
+                        link: [entity?.id],
+                    },
+                ],
+            }),
         });
     }
 }
-
-@Component({
-    standalone: true,
-    selector: 'vdr-custom-stock-control',
-    imports: [SharedModule],
-    template: `
-        <vdr-page-block>Stock control!</vdr-page-block>
-    `,
-})
-export class StockControlComponent {
-    // component logic
-}

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

@@ -6,21 +6,13 @@ import {
     CollectionFragment,
     createResolveData,
     detailBreadcrumb,
-    FacetWithValuesFragment,
     GetProductWithVariantsQuery,
     PageComponent,
     PageService,
 } from '@vendure/admin-ui/core';
 import { map } from 'rxjs/operators';
-
-import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
-import { FacetDetailComponent } from './components/facet-detail/facet-detail.component';
 import { ProductOptionsEditorComponent } from './components/product-options-editor/product-options-editor.component';
 import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
-import { AssetResolver } from './providers/routing/asset-resolver';
-import { CollectionResolver } from './providers/routing/collection-resolver';
-import { FacetResolver } from './providers/routing/facet-resolver';
-import { ProductResolver } from './providers/routing/product-resolver';
 import { ProductVariantsResolver } from './providers/routing/product-variants-resolver';
 
 export const createRoutes = (pageService: PageService): Route[] => [
@@ -36,10 +28,9 @@ export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: 'products/:id',
         component: PageComponent,
-        resolve: createResolveData(ProductResolver),
         data: {
             locationId: 'product-detail',
-            breadcrumb: productBreadcrumb,
+            breadcrumb: { label: _('breadcrumb.products'), link: ['../', 'products'] },
         },
         children: pageService.getPageTabRoutes('product-detail'),
     },
@@ -48,6 +39,7 @@ export const createRoutes = (pageService: PageService): Route[] => [
         component: PageComponent,
         data: {
             locationId: 'product-variant-detail',
+            breadcrumb: { label: _('breadcrumb.products'), link: ['../', 'products'] },
         },
         children: pageService.getPageTabRoutes('product-variant-detail'),
     },
@@ -80,11 +72,10 @@ export const createRoutes = (pageService: PageService): Route[] => [
     },
     {
         path: 'facets/:id',
-        component: FacetDetailComponent,
-        resolve: createResolveData(FacetResolver),
-        canDeactivate: [CanDeactivateDetailGuard],
+        component: PageComponent,
         data: {
-            breadcrumb: facetBreadcrumb,
+            locationId: 'facet-detail',
+            breadcrumb: { label: _('breadcrumb.facets'), link: ['../', 'facets'] },
         },
         children: pageService.getPageTabRoutes('facet-detail'),
     },
@@ -100,11 +91,9 @@ export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: 'collections/:id',
         component: PageComponent,
-        resolve: createResolveData(CollectionResolver),
         data: {
             locationId: 'collection-detail',
-            breadcrumb: collectionBreadcrumb,
-            canDeactivate: [CanDeactivateDetailGuard],
+            breadcrumb: { label: _('breadcrumb.collections'), link: ['../', 'collections'] },
         },
         children: pageService.getPageTabRoutes('collection-detail'),
     },
@@ -119,25 +108,15 @@ export const createRoutes = (pageService: PageService): Route[] => [
     },
     {
         path: 'assets/:id',
-        component: AssetDetailComponent,
-        resolve: createResolveData(AssetResolver),
+        component: PageComponent,
         data: {
-            breadcrumb: assetBreadcrumb,
+            locationId: 'asset-detail',
+            breadcrumb: { label: _('breadcrumb.assets'), link: ['../', 'assets'] },
         },
         children: pageService.getPageTabRoutes('asset-detail'),
     },
 ];
 
-export function productBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<NonNullable<GetProductWithVariantsQuery['product']>>({
-        entity: data.entity,
-        id: params.id,
-        breadcrumbKey: 'breadcrumb.products',
-        getName: product => product.name,
-        route: 'products',
-    });
-}
-
 export function productVariantEditorBreadcrumb(data: any, params: any) {
     return data.entity.pipe(
         map((entity: any) => [
@@ -175,33 +154,3 @@ export function productOptionsEditorBreadcrumb(data: any, params: any) {
         ]),
     );
 }
-
-export function facetBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<FacetWithValuesFragment>({
-        entity: data.entity,
-        id: params.id,
-        breadcrumbKey: 'breadcrumb.facets',
-        getName: facet => facet.name,
-        route: 'facets',
-    });
-}
-
-export function collectionBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<CollectionFragment>({
-        entity: data.entity,
-        id: params.id,
-        breadcrumbKey: 'breadcrumb.collections',
-        getName: collection => collection.name,
-        route: 'collections',
-    });
-}
-
-export function assetBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<AssetFragment>({
-        entity: data.entity,
-        id: params.id,
-        breadcrumbKey: 'breadcrumb.assets',
-        getName: asset => asset.name,
-        route: 'assets',
-    });
-}

+ 25 - 23
packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.html

@@ -1,24 +1,26 @@
-<vdr-action-bar>
-    <vdr-ab-left>
-        <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-    </vdr-ab-left>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left></vdr-ab-left>
 
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="asset-detail"></vdr-action-bar-items>
-        <button
-            *vdrIfPermissions="['UpdateCatalog', 'UpdateAsset']"
-            class="btn btn-primary"
-            (click)="save()"
-            [disabled]="detailForm.invalid || detailForm.pristine"
-        >
-            {{ 'common.update' | translate }}
-        </button>
-    </vdr-ab-right>
-</vdr-action-bar>
-<vdr-asset-preview
-    [asset]="entity$ | async"
-    [editable]="true"
-    [customFields]="customFields"
-    [customFieldsForm]="detailForm.get('customFields')"
-    (assetChange)="onAssetChange($event)"
-></vdr-asset-preview>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="asset-detail"></vdr-action-bar-items>
+            <button
+                *vdrIfPermissions="['UpdateCatalog', 'UpdateAsset']"
+                class="btn btn-primary"
+                (click)="save()"
+                [disabled]="detailForm.invalid || detailForm.pristine"
+            >
+                {{ 'common.update' | translate }}
+            </button>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+<vdr-page-block>
+    <vdr-asset-preview
+        [asset]="entity$ | async"
+        [editable]="true"
+        [customFields]="customFields"
+        [customFieldsForm]="detailForm.get('customFields')"
+        (assetChange)="onAssetChange($event)"
+    />
+</vdr-page-block>

+ 35 - 23
packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.ts

@@ -1,17 +1,31 @@
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
-import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
+import { FormControl, FormGroup, UntypedFormBuilder } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     Asset,
-    BaseDetailComponent,
-    CustomFieldConfig,
+    ASSET_FRAGMENT,
+    AssetDetailQueryDocument,
+    AssetDetailQueryQuery,
     DataService,
-    GetAssetQuery,
     LanguageCode,
     NotificationService,
-    ServerConfigService,
+    TAG_FRAGMENT,
+    TypedBaseDetailComponent,
 } from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
+
+export const ASSET_DETAIL_QUERY = gql`
+    query AssetDetailQuery($id: ID!) {
+        asset(id: $id) {
+            ...Asset
+            tags {
+                ...Tag
+            }
+        }
+    }
+    ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
+`;
 
 @Component({
     selector: 'vdr-asset-detail',
@@ -20,32 +34,27 @@ import {
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AssetDetailComponent
-    extends BaseDetailComponent<NonNullable<GetAssetQuery['asset']>>
+    extends TypedBaseDetailComponent<typeof AssetDetailQueryDocument, 'asset'>
     implements OnInit, OnDestroy
 {
-    detailForm = new UntypedFormGroup({});
-    customFields: CustomFieldConfig[];
+    readonly customFields = this.getCustomFieldConfig('Asset');
+    detailForm = new FormGroup({
+        name: new FormControl(''),
+        tags: new FormControl([] as string[]),
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
 
     constructor(
-        router: Router,
-        route: ActivatedRoute,
-        serverConfigService: ServerConfigService,
         private notificationService: NotificationService,
         protected dataService: DataService,
         private formBuilder: UntypedFormBuilder,
     ) {
-        super(route, router, serverConfigService, dataService);
-        this.customFields = this.getCustomFieldConfig('Asset');
+        super();
     }
 
     ngOnInit() {
-        this.detailForm = new UntypedFormGroup({
-            name: new UntypedFormControl(''),
-            tags: new UntypedFormControl([]),
-            customFields: this.formBuilder.group(
-                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-            ),
-        });
         this.init();
     }
 
@@ -79,9 +88,12 @@ export class AssetDetailComponent
             );
     }
 
-    protected setFormValues(entity: NonNullable<GetAssetQuery['asset']>, languageCode: LanguageCode): void {
+    protected setFormValues(
+        entity: NonNullable<AssetDetailQueryQuery['asset']>,
+        languageCode: LanguageCode,
+    ): void {
         this.detailForm.get('name')?.setValue(entity.name);
-        this.detailForm.get('tags')?.setValue(entity.tags);
+        this.detailForm.get('tags')?.setValue(entity.tags.map(t => t.id));
         if (this.customFields.length) {
             this.setCustomFieldFormValues(this.customFields, this.detailForm.get(['customFields']), entity);
         }

+ 37 - 29
packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.html

@@ -31,15 +31,9 @@
         >
     </vdr-action-bar>
 </vdr-page-block>
-<form class="form" [formGroup]="detailForm" *ngIf="entity$ | async as collection">
+<form class="form" [formGroup]="detailForm">
     <vdr-page-detail-layout>
         <vdr-page-detail-sidebar>
-            <vdr-card>
-                <vdr-page-entity-info
-                    *ngIf="entity$ | async as entity"
-                    [entity]="entity"
-                ></vdr-page-entity-info>
-            </vdr-card>
             <vdr-card>
                 <vdr-form-field [label]="'catalog.visibility' | translate" for="visibility">
                     <clr-toggle-wrapper>
@@ -59,6 +53,12 @@
                     </clr-toggle-wrapper>
                 </vdr-form-field>
             </vdr-card>
+            <vdr-card>
+                <vdr-page-entity-info
+                    *ngIf="entity$ | async as entity"
+                    [entity]="entity"
+                ></vdr-page-entity-info>
+            </vdr-card>
         </vdr-page-detail-sidebar>
 
         <vdr-page-block
@@ -108,7 +108,7 @@
                         [readonly]="!(updatePermission | hasPermission)"
                     />
                 </vdr-form-field>
-                <vdr-form-field [label]="'common.description' | translate" for="slug">
+                <vdr-form-field class="card-span" [label]="'common.description' | translate" for="slug">
                     <vdr-rich-text-editor
                         formControlName="description"
                         [readonly]="!(updatePermission | hasPermission)"
@@ -132,16 +132,21 @@
                 [entity$]="entity$"
                 [detailForm]="detailForm"
             ></vdr-custom-detail-component-host>
-            <vdr-card [title]="'common.assets' | translate">
+            <vdr-card [title]="'catalog.assets' | translate">
                 <vdr-assets
-                    [assets]="collection.assets"
-                    [featuredAsset]="collection.featuredAsset"
+                    class="card-span"
+                    [assets]="entity?.assets"
+                    [featuredAsset]="entity?.featuredAsset"
                     [updatePermissions]="updatePermission"
                     (change)="assetChanges = $event"
                 ></vdr-assets>
             </vdr-card>
             <vdr-card [title]="'catalog.filters' | translate">
-                <vdr-form-field [label]="'catalog.filter-inheritance' | translate" for="inheritFilters">
+                <vdr-form-field
+                    class="card-span"
+                    [label]="'catalog.filter-inheritance' | translate"
+                    for="inheritFilters"
+                >
                     <clr-toggle-wrapper>
                         <input
                             type="checkbox"
@@ -160,7 +165,7 @@
                         </label>
                     </clr-toggle-wrapper>
                 </vdr-form-field>
-                <div formArrayName="filters" class="mt-4">
+                <div formArrayName="filters" class="card-span">
                     <ng-container *ngFor="let filter of filters; index as i; trackBy: trackByFn">
                         <vdr-configurable-input
                             (remove)="removeFilter(i)"
@@ -193,8 +198,9 @@
                 </div>
             </vdr-card>
 
-            <vdr-card [title]="'common.contents' | translate">
+            <vdr-card [title]="'common.contents' | translate" [paddingX]="false">
                 <vdr-collection-contents
+                    class="card-span"
                     [collectionId]="id"
                     [parentId]="parentId$ | async"
                     [updatedFilters]="updatedFilters$ | async"
@@ -203,22 +209,24 @@
                     #collectionContents
                 >
                     <ng-template let-count>
-                        <div class="contents-title">
-                            {{ 'catalog.collection-contents' | translate }} ({{
-                                'common.results-count' | translate : { count: count }
-                            }})
+                        <div class="ml-3">
+                            <div class="contents-title">
+                                {{ 'catalog.collection-contents' | translate }} ({{
+                                    'common.results-count' | translate : { count: count }
+                                }})
+                            </div>
+                            <clr-checkbox-wrapper [class.disabled]="detailForm.get('filters')?.pristine">
+                                <input
+                                    type="checkbox"
+                                    clrCheckbox
+                                    [ngModelOptions]="{ standalone: true }"
+                                    [disabled]="detailForm.get('filters')?.pristine"
+                                    [ngModel]="livePreview"
+                                    (ngModelChange)="toggleLivePreview()"
+                                />
+                                <label>{{ 'catalog.live-preview-contents' | translate }}</label>
+                            </clr-checkbox-wrapper>
                         </div>
-                        <clr-checkbox-wrapper [class.disabled]="detailForm.get('filters')?.pristine">
-                            <input
-                                type="checkbox"
-                                clrCheckbox
-                                [ngModelOptions]="{ standalone: true }"
-                                [disabled]="detailForm.get('filters')?.pristine"
-                                [ngModel]="livePreview"
-                                (ngModelChange)="toggleLivePreview()"
-                            />
-                            <label>{{ 'catalog.live-preview-contents' | translate }}</label>
-                        </clr-checkbox-wrapper>
                     </ng-template>
                 </vdr-collection-contents>
             </vdr-card>

+ 84 - 80
packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts

@@ -6,26 +6,19 @@ import {
     OnInit,
     ViewChild,
 } from '@angular/core';
-import {
-    UntypedFormArray,
-    UntypedFormBuilder,
-    UntypedFormControl,
-    UntypedFormGroup,
-    Validators,
-} from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
+import { FormBuilder, UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     Asset,
-    BaseDetailComponent,
     Collection,
+    COLLECTION_FRAGMENT,
+    CollectionDetailQueryDocument,
     CollectionFragment,
     ConfigurableOperation,
     ConfigurableOperationDefinition,
     ConfigurableOperationInput,
     CreateCollectionInput,
     createUpdatedTranslatable,
-    CustomFieldConfig,
     DataService,
     encodeConfigArgValue,
     findTranslation,
@@ -35,16 +28,26 @@ import {
     ModalService,
     NotificationService,
     Permission,
-    ServerConfigService,
+    TypedBaseDetailComponent,
     unicodePatternValidator,
     UpdateCollectionInput,
 } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
+import { gql } from 'apollo-angular';
 import { combineLatest, merge, Observable, of, Subject } from 'rxjs';
 import { debounceTime, distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
 
 import { CollectionContentsComponent } from '../collection-contents/collection-contents.component';
 
+export const COLLECTION_DETAIL_QUERY = gql`
+    query CollectionDetailQuery($id: ID!) {
+        collection(id: $id) {
+            ...Collection
+        }
+    }
+    ${COLLECTION_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-collection-detail',
     templateUrl: './collection-detail.component.html',
@@ -52,11 +55,21 @@ import { CollectionContentsComponent } from '../collection-contents/collection-c
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class CollectionDetailComponent
-    extends BaseDetailComponent<CollectionFragment>
+    extends TypedBaseDetailComponent<typeof CollectionDetailQueryDocument, 'collection'>
     implements OnInit, OnDestroy
 {
-    customFields: CustomFieldConfig[];
-    detailForm: UntypedFormGroup;
+    customFields = this.getCustomFieldConfig('Collection');
+    detailForm = this.formBuilder.group({
+        name: ['', Validators.required],
+        slug: ['', unicodePatternValidator(/^[\p{Letter}0-9_-]+$/)],
+        description: '',
+        visible: false,
+        inheritFilters: true,
+        filters: this.formBuilder.array([]),
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
     assetChanges: { assets?: Asset[]; featuredAsset?: Asset } = {};
     filters: ConfigurableOperation[] = [];
     allFilters: ConfigurableOperationDefinition[] = [];
@@ -69,29 +82,14 @@ export class CollectionDetailComponent
     @ViewChild('collectionContents') contentsComponent: CollectionContentsComponent;
 
     constructor(
-        router: Router,
-        route: ActivatedRoute,
-        serverConfigService: ServerConfigService,
         private changeDetector: ChangeDetectorRef,
         protected dataService: DataService,
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         private notificationService: NotificationService,
         private modalService: ModalService,
         private localStorageService: LocalStorageService,
     ) {
-        super(route, router, serverConfigService, dataService);
-        this.customFields = this.getCustomFieldConfig('Collection');
-        this.detailForm = this.formBuilder.group({
-            name: ['', Validators.required],
-            slug: ['', unicodePatternValidator(/^[\p{Letter}0-9_-]+$/)],
-            description: '',
-            visible: false,
-            inheritFilters: true,
-            filters: this.formBuilder.array([]),
-            customFields: this.formBuilder.group(
-                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-            ),
-        });
+        super();
         this.livePreview = this.localStorageService.get('livePreviewCollectionContents') ?? false;
     }
 
@@ -147,16 +145,12 @@ export class CollectionDetailComponent
      * If creating a new Collection, automatically generate the slug based on the collection name.
      */
     updateSlug(nameValue: string) {
-        combineLatest(this.entity$, this.languageCode$)
-            .pipe(take(1))
-            .subscribe(([entity, languageCode]) => {
-                const slugControl = this.detailForm.get(['slug']);
-                const currentTranslation = findTranslation(entity, languageCode);
-                const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
-                if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
-                    slugControl.setValue(normalizeString(`${nameValue}`, '-'));
-                }
-            });
+        const slugControl = this.detailForm.get(['slug']);
+        const currentTranslation = this.entity ? findTranslation(this.entity, this.languageCode) : undefined;
+        const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
+        if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
+            slugControl.setValue(normalizeString(`${nameValue}`, '-'));
+        }
     }
 
     addFilter(collectionFilter: ConfigurableOperation) {
@@ -195,38 +189,48 @@ export class CollectionDetailComponent
         if (!this.detailForm.dirty) {
             return;
         }
-        combineLatest(this.entity$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([category, languageCode]) => {
-                    const input = this.getUpdatedCollection(
-                        category,
-                        this.detailForm,
-                        languageCode,
-                    ) as CreateCollectionInput;
-                    const parentId = this.route.snapshot.paramMap.get('parentId');
-                    if (parentId) {
-                        input.parentId = parentId;
-                    }
-                    return this.dataService.collection.createCollection(input);
-                }),
-            )
-            .subscribe(
-                data => {
-                    this.notificationService.success(_('common.notify-create-success'), {
-                        entity: 'Collection',
-                    });
-                    this.assetChanges = {};
-                    this.detailForm.markAsPristine();
-                    this.changeDetector.markForCheck();
-                    this.router.navigate(['../', data.createCollection.id], { relativeTo: this.route });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-create-error'), {
-                        entity: 'Collection',
-                    });
-                },
-            );
+        const input = this.getUpdatedCollection(
+            {
+                id: '',
+                createdAt: '',
+                updatedAt: '',
+                languageCode: this.languageCode,
+                name: '',
+                slug: '',
+                isPrivate: false,
+                breadcrumbs: [],
+                description: '',
+                featuredAsset: null,
+                assets: [],
+                translations: [],
+                inheritFilters: true,
+                filters: [],
+                parent: {} as any,
+                children: null,
+            },
+            this.detailForm,
+            this.languageCode,
+        ) as CreateCollectionInput;
+        const parentId = this.route.snapshot.paramMap.get('parentId');
+        if (parentId) {
+            input.parentId = parentId;
+        }
+        this.dataService.collection.createCollection(input).subscribe(
+            data => {
+                this.notificationService.success(_('common.notify-create-success'), {
+                    entity: 'Collection',
+                });
+                this.assetChanges = {};
+                this.detailForm.markAsPristine();
+                this.changeDetector.markForCheck();
+                this.router.navigate(['../', data.createCollection.id], { relativeTo: this.route });
+            },
+            err => {
+                this.notificationService.error(_('common.notify-create-error'), {
+                    entity: 'Collection',
+                });
+            },
+        );
     }
 
     save() {
@@ -310,7 +314,7 @@ export class CollectionDetailComponent
      */
     private getUpdatedCollection(
         category: CollectionFragment,
-        form: UntypedFormGroup,
+        form: typeof this.detailForm,
         languageCode: LanguageCode,
     ): CreateCollectionInput | UpdateCollectionInput {
         const updatedCategory = createUpdatedTranslatable({
@@ -342,11 +346,11 @@ export class CollectionDetailComponent
         formValueOperations: any,
     ): ConfigurableOperationInput[] {
         return operations.map((o, i) => ({
-                code: o.code,
-                arguments: Object.entries(formValueOperations[i].args).map(([name, value], j) => ({
-                        name,
-                        value: encodeConfigArgValue(value),
-                    })),
-            }));
+            code: o.code,
+            arguments: Object.entries(formValueOperations[i].args).map(([name, value], j) => ({
+                name,
+                value: encodeConfigArgValue(value),
+            })),
+        }));
     }
 }

+ 10 - 0
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html

@@ -123,6 +123,16 @@
                     {{ collection.slug }}
                 </ng-template>
             </vdr-dt2-column>
+            <vdr-dt2-column [heading]="'common.visibility' | translate">
+                <ng-template let-collection="item">
+                    <vdr-chip *ngIf="collection.isPrivate" colorType="warning">{{
+                        'common.private' | translate
+                        }}</vdr-chip>
+                    <vdr-chip *ngIf="!collection.isPrivate" colorType="success">{{
+                        'common.public' | translate
+                        }}</vdr-chip>
+                </ng-template>
+            </vdr-dt2-column>
             <vdr-dt2-column [heading]="'common.view-contents' | translate" [optional]="false">
                 <ng-template let-collection="item">
                     <a

+ 8 - 0
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts

@@ -46,6 +46,14 @@ export class CollectionListComponent
             type: { kind: 'text' },
             filterField: 'slug',
         })
+        .addFilter({
+            name: 'visibility',
+            type: { kind: 'boolean' },
+            label: _('common.visibility'),
+            toFilterInput: value => ({
+                isPrivate: { eq: !value },
+            }),
+        })
         .addCustomFieldFilters(this.customFields)
         .connectToRoute(this.route);
     readonly sorts = this.dataTableService

+ 173 - 155
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.html

@@ -1,166 +1,184 @@
-<vdr-action-bar>
-    <vdr-ab-left>
-        <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-        <vdr-language-selector
-            [disabled]="isNew$ | async"
-            [availableLanguageCodes]="availableLanguages$ | async"
-            [currentLanguageCode]="languageCode$ | async"
-            (languageCodeChange)="setLanguage($event)"
-        ></vdr-language-selector>
-    </vdr-ab-left>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <vdr-language-selector
+                [disabled]="isNew$ | async"
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="languageCode$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </vdr-ab-left>
 
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="facet-detail"></vdr-action-bar-items>
-        <button
-            class="btn btn-primary"
-            *ngIf="isNew$ | async; else updateButton"
-            (click)="create()"
-            [disabled]="detailForm.invalid || detailForm.pristine"
-        >
-            {{ 'common.create' | translate }}
-        </button>
-        <ng-template #updateButton>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="facet-detail"></vdr-action-bar-items>
             <button
-                *vdrIfPermissions="updatePermission"
                 class="btn btn-primary"
-                (click)="save()"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
                 [disabled]="detailForm.invalid || detailForm.pristine"
             >
-                {{ 'common.update' | translate }}
+                {{ 'common.create' | translate }}
             </button>
-        </ng-template>
-    </vdr-ab-right>
-</vdr-action-bar>
-
-<form class="form" [formGroup]="detailForm" *ngIf="entity$ | async as facet">
-    <section class="form-block" formGroupName="facet">
-        <vdr-form-field [label]="'catalog.visibility' | translate" for="visibility">
-            <clr-toggle-wrapper>
-                <input
-                    type="checkbox"
-                    clrToggle
-                    [vdrDisabled]="!(updatePermission | hasPermission)"
-                    formControlName="visible"
-                    id="visibility"
-                />
-                <label class="visible-toggle">
-                    <ng-container *ngIf="detailForm.value.facet.visible; else private">{{
-                        'catalog.public' | translate
-                    }}</ng-container>
-                    <ng-template #private>{{ 'catalog.private' | translate }}</ng-template>
-                </label>
-            </clr-toggle-wrapper>
-        </vdr-form-field>
-        <vdr-form-field [label]="'common.name' | translate" for="name">
-            <input
-                id="name"
-                type="text"
-                formControlName="name"
-                [readonly]="!(updatePermission | hasPermission)"
-                (input)="updateCode(facet.code, $event.target.value)"
-            />
-        </vdr-form-field>
-        <vdr-form-field
-            [label]="'common.code' | translate"
-            for="code"
-            [readOnlyToggle]="updatePermission | hasPermission"
-        >
-            <input
-                id="code"
-                type="text"
-                [readonly]="!(updatePermission | hasPermission)"
-                formControlName="code"
-            />
-        </vdr-form-field>
-
-        <section formGroupName="customFields" *ngIf="customFields.length">
-            <label>{{ 'common.custom-fields' | translate }}</label>
-            <vdr-tabbed-custom-fields
-                entityName="Facet"
-                [customFields]="customFields"
-                [customFieldsFormGroup]="detailForm.get(['facet', 'customFields'])"
-                [readonly]="!(updatePermission | hasPermission)"
-            ></vdr-tabbed-custom-fields>
-        </section>
-        <vdr-custom-detail-component-host
-            locationId="facet-detail"
-            [entity$]="entity$"
-            [detailForm]="detailForm"
-        ></vdr-custom-detail-component-host>
-    </section>
-
-    <section class="form-block" *ngIf="!(isNew$ | async)">
-        <label>{{ 'catalog.facet-values' | translate }}</label>
-
-        <table class="facet-values-list table" formArrayName="values" *ngIf="0 < getValuesFormArray().length">
-            <thead>
-                <tr>
-                    <th></th>
-                    <th>{{ 'common.name' | translate }}</th>
-                    <th>{{ 'common.code' | translate }}</th>
-                    <ng-container *ngIf="customValueFields.length">
-                        <th>{{ 'common.custom-fields' | translate }}</th>
-                    </ng-container>
-                    <th></th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr class="facet-value" *ngFor="let value of values; let i = index" [formGroupName]="i">
-                    <td class="align-middle">
-                        <vdr-entity-info [entity]="value"></vdr-entity-info>
-                    </td>
-                    <td class="align-middle">
+            <ng-template #updateButton>
+                <button
+                    *vdrIfPermissions="updatePermission"
+                    class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="detailForm.invalid || detailForm.pristine"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+<form class="form" [formGroup]="detailForm">
+    <vdr-page-detail-layout>
+        <vdr-page-detail-sidebar formGroupName="facet">
+            <vdr-card>
+                <vdr-form-field [label]="'catalog.visibility' | translate" for="visibility">
+                    <clr-toggle-wrapper>
                         <input
-                            type="text"
-                            formControlName="name"
-                            [readonly]="!(updatePermission | hasPermission)"
-                            (input)="updateValueCode(facet.values[i]?.code, $event.target.value, i)"
+                            type="checkbox"
+                            clrToggle
+                            [vdrDisabled]="!(updatePermission | hasPermission)"
+                            formControlName="visible"
+                            id="visibility"
                         />
-                    </td>
-                    <td class="align-middle"><input type="text" formControlName="code" readonly /></td>
-                    <td class="" *ngIf="customValueFields.length">
-                        <vdr-tabbed-custom-fields
-                            entityName="FacetValue"
-                            [customFields]="customValueFields"
-                            [compact]="true"
-                            [customFieldsFormGroup]="detailForm.get(['values', i, 'customFields'])"
-                            [readonly]="!(updatePermission | hasPermission)"
-                        ></vdr-tabbed-custom-fields>
-                    </td>
-                    <td class="align-middle">
-                        <vdr-dropdown>
-                            <button type="button" class="btn btn-link btn-sm" vdrDropdownTrigger>
-                                {{ 'common.actions' | translate }}
-                                <clr-icon shape="caret down"></clr-icon>
-                            </button>
-                            <vdr-dropdown-menu vdrPosition="bottom-right">
-                                <button
-                                    type="button"
-                                    class="delete-button"
-                                    (click)="deleteFacetValue(facet.values[i]?.id, i)"
-                                    [disabled]="!(updatePermission | hasPermission)"
-                                    vdrDropdownItem
-                                >
-                                    <clr-icon shape="trash" class="is-danger"></clr-icon>
-                                    {{ 'common.delete' | translate }}
-                                </button>
-                            </vdr-dropdown-menu>
-                        </vdr-dropdown>
-                    </td>
-                </tr>
-            </tbody>
-        </table>
+                        <label class="visible-toggle">
+                            <ng-container *ngIf="detailForm.value.facet?.visible; else private">{{
+                                'catalog.public' | translate
+                            }}</ng-container>
+                            <ng-template #private>{{ 'catalog.private' | translate }}</ng-template>
+                        </label>
+                    </clr-toggle-wrapper>
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card>
+                <vdr-page-entity-info *ngIf="entity$ | async as entity" [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+        <vdr-page-block>
+            <vdr-card formGroupName="facet">
+                <vdr-form-field [label]="'common.name' | translate" for="name">
+                    <input
+                        id="name"
+                        type="text"
+                        formControlName="name"
+                        [readonly]="!(updatePermission | hasPermission)"
+                        (input)="updateCode(entity?.code, $event.target.value)"
+                    />
+                </vdr-form-field>
+                <vdr-form-field [label]="'common.code' | translate" for="code">
+                    <input
+                        id="code"
+                        type="text"
+                        [readonly]="!(updatePermission | hasPermission)"
+                        formControlName="code"
+                    />
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card [title]="'common.custom-fields' | translate" *ngIf="customFields.length">
+                <vdr-tabbed-custom-fields
+                    entityName="Facet"
+                    [customFields]="customFields"
+                    [customFieldsFormGroup]="detailForm.get(['facet', 'customFields'])"
+                    [readonly]="!(updatePermission | hasPermission)"
+                />
+            </vdr-card>
 
-        <div>
-            <button
-                type="button"
-                class="btn btn-secondary"
-                *vdrIfPermissions="['CreateCatalog', 'CreateFacet']"
-                (click)="addFacetValue()"
+            <vdr-custom-detail-component-host
+                locationId="facet-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
+
+            <vdr-card
+                *ngIf="!(isNew$ | async)"
+                [title]="'catalog.facet-values' | translate"
+                [paddingX]="false"
             >
-                <clr-icon shape="add"></clr-icon>
-                {{ 'catalog.add-facet-value' | translate }}
-            </button>
-        </div>
-    </section>
+                <table
+                    class="facet-values-list table card-span"
+                    formArrayName="values"
+                    *ngIf="0 < getValuesFormArray().length"
+                >
+                    <thead>
+                        <tr>
+                            <th></th>
+                            <th>{{ 'common.name' | translate }}</th>
+                            <th>{{ 'common.code' | translate }}</th>
+                            <ng-container *ngIf="customValueFields.length">
+                                <th>{{ 'common.custom-fields' | translate }}</th>
+                            </ng-container>
+                            <th></th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr
+                            class="facet-value"
+                            *ngFor="let value of values; let i = index"
+                            [formGroup]="detailForm.get(['values', i])"
+                        >
+                            <td class="align-middle">
+                                <vdr-entity-info [entity]="value"></vdr-entity-info>
+                            </td>
+                            <td class="align-middle">
+                                <input
+                                    type="text"
+                                    formControlName="name"
+                                    [readonly]="!(updatePermission | hasPermission)"
+                                    (input)="updateValueCode(entity?.values[i]?.code, $event.target.value, i)"
+                                />
+                            </td>
+                            <td class="align-middle">
+                                <input type="text" formControlName="code" />
+                            </td>
+                            <td class="" *ngIf="customValueFields.length">
+                                <vdr-tabbed-custom-fields
+                                    entityName="FacetValue"
+                                    [customFields]="customValueFields"
+                                    [compact]="true"
+                                    [customFieldsFormGroup]="detailForm.get(['values', i, 'customFields'])"
+                                    [readonly]="!(updatePermission | hasPermission)"
+                                ></vdr-tabbed-custom-fields>
+                            </td>
+                            <td class="align-middle">
+                                <vdr-dropdown>
+                                    <button type="button" class="btn btn-link btn-sm" vdrDropdownTrigger>
+                                        {{ 'common.actions' | translate }}
+                                        <clr-icon shape="caret down"></clr-icon>
+                                    </button>
+                                    <vdr-dropdown-menu vdrPosition="bottom-right">
+                                        <button
+                                            type="button"
+                                            class="delete-button"
+                                            (click)="deleteFacetValue(entity?.values[i]?.id, i)"
+                                            [disabled]="!(updatePermission | hasPermission)"
+                                            vdrDropdownItem
+                                        >
+                                            <clr-icon shape="trash" class="is-danger"></clr-icon>
+                                            {{ 'common.delete' | translate }}
+                                        </button>
+                                    </vdr-dropdown-menu>
+                                </vdr-dropdown>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+
+                <div>
+                    <button
+                        type="button"
+                        class="btn btn-secondary"
+                        *vdrIfPermissions="['CreateCatalog', 'CreateFacet']"
+                        (click)="addFacetValue()"
+                    >
+                        <clr-icon shape="add"></clr-icon>
+                        {{ 'catalog.add-facet-value' | translate }}
+                    </button>
+                </div>
+            </vdr-card>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
 </form>

+ 99 - 81
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts

@@ -1,35 +1,44 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import {
+    FormBuilder,
     UntypedFormArray,
-    UntypedFormBuilder,
     UntypedFormControl,
     UntypedFormGroup,
     Validators,
 } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    BaseDetailComponent,
     CreateFacetInput,
     CreateFacetValueInput,
     createUpdatedTranslatable,
-    CustomFieldConfig,
     DataService,
     DeletionResult,
+    FACET_WITH_VALUES_FRAGMENT,
     FacetWithValuesFragment,
     findTranslation,
+    GetFacetDetailDocument,
     LanguageCode,
     ModalService,
     NotificationService,
     Permission,
-    ServerConfigService,
+    TypedBaseDetailComponent,
     UpdateFacetInput,
     UpdateFacetValueInput,
 } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { gql } from 'apollo-angular';
 import { combineLatest, EMPTY, forkJoin, Observable } from 'rxjs';
-import { map, mapTo, mergeMap, switchMap, take } from 'rxjs/operators';
+import { map, mergeMap, switchMap, take } from 'rxjs/operators';
+
+export const FACET_DETAIL_QUERY = gql`
+    query GetFacetDetail($id: ID!) {
+        facet(id: $id) {
+            ...FacetWithValues
+        }
+    }
+    ${FACET_WITH_VALUES_FRAGMENT}
+`;
 
 @Component({
     selector: 'vdr-facet-detail',
@@ -38,39 +47,38 @@ import { map, mapTo, mergeMap, switchMap, take } from 'rxjs/operators';
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class FacetDetailComponent
-    extends BaseDetailComponent<FacetWithValuesFragment>
+    extends TypedBaseDetailComponent<typeof GetFacetDetailDocument, 'facet'>
     implements OnInit, OnDestroy
 {
-    customFields: CustomFieldConfig[];
-    customValueFields: CustomFieldConfig[];
-    detailForm: UntypedFormGroup;
-    values: Array<FacetWithValuesFragment['values'][number] | { name: string; code: string }>;
+    readonly customFields = this.getCustomFieldConfig('Facet');
+    readonly customValueFields = this.getCustomFieldConfig('FacetValue');
+    detailForm = this.formBuilder.group({
+        facet: this.formBuilder.group({
+            code: ['', Validators.required],
+            name: '',
+            visible: true,
+            customFields: this.formBuilder.group(
+                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+            ),
+        }),
+        values: this.formBuilder.array<{
+            id: string;
+            name: string;
+            code: string;
+            customFields: any;
+        }>([]),
+    });
+    values: Array<FacetWithValuesFragment['values'][number] | { name: string; code: string }> = [];
     readonly updatePermission = [Permission.UpdateCatalog, Permission.UpdateFacet];
 
     constructor(
-        router: Router,
-        route: ActivatedRoute,
-        serverConfigService: ServerConfigService,
         private changeDetector: ChangeDetectorRef,
         protected dataService: DataService,
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         private notificationService: NotificationService,
         private modalService: ModalService,
     ) {
-        super(route, router, serverConfigService, dataService);
-        this.customFields = this.getCustomFieldConfig('Facet');
-        this.customValueFields = this.getCustomFieldConfig('FacetValue');
-        this.detailForm = this.formBuilder.group({
-            facet: this.formBuilder.group({
-                code: ['', Validators.required],
-                name: '',
-                visible: true,
-                customFields: this.formBuilder.group(
-                    this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-                ),
-            }),
-            values: this.formBuilder.array([]),
-        });
+        super();
     }
 
     ngOnInit() {
@@ -114,6 +122,7 @@ export class FacetDetailComponent
                 id: '',
                 name: ['', Validators.required],
                 code: '',
+                customFields: this.formBuilder.group({}),
             });
             const newValue: any = { name: '', code: '' };
             if (this.customValueFields.length) {
@@ -133,75 +142,76 @@ export class FacetDetailComponent
     }
 
     create() {
-        const facetForm = this.detailForm.get('facet');
+        const facetForm = this.detailForm.get('facet') as (typeof this.detailForm)['controls']['facet'];
         if (!facetForm || !facetForm.dirty) {
             return;
         }
-        combineLatest(this.entity$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([facet, languageCode]) => {
-                    const newFacet = this.getUpdatedFacet(
-                        facet,
-                        facetForm as UntypedFormGroup,
-                        languageCode,
-                    ) as CreateFacetInput;
-                    return this.dataService.facet.createFacet(newFacet);
-                }),
-                switchMap(data => this.dataService.facet.getAllFacets().single$.pipe(mapTo(data))),
-            )
-            .subscribe(
-                data => {
-                    this.notificationService.success(_('common.notify-create-success'), { entity: 'Facet' });
-                    this.detailForm.markAsPristine();
-                    this.changeDetector.markForCheck();
-                    this.router.navigate(['../', data.createFacet.id], { relativeTo: this.route });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-create-error'), {
-                        entity: 'Facet',
-                    });
-                },
-            );
+        const newFacet = this.getUpdatedFacet(
+            {
+                id: '',
+                createdAt: '',
+                updatedAt: '',
+                isPrivate: false,
+                languageCode: this.languageCode,
+                name: '',
+                code: '',
+                translations: [],
+                values: [],
+            },
+            facetForm,
+            this.languageCode,
+        ) as CreateFacetInput;
+        this.dataService.facet.createFacet(newFacet).subscribe(
+            data => {
+                this.notificationService.success(_('common.notify-create-success'), { entity: 'Facet' });
+                this.detailForm.markAsPristine();
+                this.changeDetector.markForCheck();
+                this.router.navigate(['../', data.createFacet.id], { relativeTo: this.route });
+            },
+            err => {
+                this.notificationService.error(_('common.notify-create-error'), {
+                    entity: 'Facet',
+                });
+            },
+        );
     }
 
     save() {
+        const valuesArray = this.detailForm.get('values') as (typeof this.detailForm)['controls']['values'];
         combineLatest(this.entity$, this.languageCode$)
             .pipe(
                 take(1),
                 mergeMap(([facet, languageCode]) => {
-                    const facetGroup = this.detailForm.get('facet');
+                    const facetForm = this.detailForm.get(
+                        'facet',
+                    ) as (typeof this.detailForm)['controls']['facet'];
                     const updateOperations: Array<Observable<any>> = [];
 
-                    if (facetGroup && facetGroup.dirty) {
+                    if (facetForm && facetForm.dirty) {
                         const newFacet = this.getUpdatedFacet(
                             facet,
-                            facetGroup as UntypedFormGroup,
+                            facetForm,
                             languageCode,
                         ) as UpdateFacetInput;
                         if (newFacet) {
                             updateOperations.push(this.dataService.facet.updateFacet(newFacet));
                         }
                     }
-                    const valuesArray = this.detailForm.get('values');
                     if (valuesArray && valuesArray.dirty) {
-                        const createdValues = this.getCreatedFacetValues(
-                            facet,
-                            valuesArray as UntypedFormArray,
-                            languageCode,
-                        );
+                        const createdValues = this.getCreatedFacetValues(facet, valuesArray, languageCode);
                         if (createdValues.length) {
                             updateOperations.push(
-                                this.dataService.facet
-                                    .createFacetValues(createdValues)
-                                    .pipe(switchMap(() => this.dataService.facet.getFacet(this.id).single$)),
+                                this.dataService.facet.createFacetValues(createdValues).pipe(
+                                    switchMap(
+                                        () =>
+                                            this.dataService.query(GetFacetDetailDocument, {
+                                                id: this.id,
+                                            }).single$,
+                                    ),
+                                ),
                             );
                         }
-                        const updatedValues = this.getUpdatedFacetValues(
-                            facet,
-                            valuesArray as UntypedFormArray,
-                            languageCode,
-                        );
+                        const updatedValues = this.getUpdatedFacetValues(facet, valuesArray, languageCode);
                         if (updatedValues.length) {
                             updateOperations.push(this.dataService.facet.updateFacetValues(updatedValues));
                         }
@@ -209,7 +219,6 @@ export class FacetDetailComponent
 
                     return forkJoin(updateOperations);
                 }),
-                switchMap(() => this.dataService.facet.getAllFacets().single$),
             )
             .subscribe(
                 () => {
@@ -246,7 +255,13 @@ export class FacetDetailComponent
                         );
                     }
                 }),
-                switchMap(deleted => (deleted ? this.dataService.facet.getFacet(this.id).single$ : [])),
+                switchMap(deleted =>
+                    deleted
+                        ? this.dataService.query(GetFacetDetailDocument, {
+                              id: this.id,
+                          }).single$
+                        : [],
+                ),
             )
             .subscribe(
                 () => {
@@ -362,7 +377,7 @@ export class FacetDetailComponent
      */
     private getUpdatedFacet(
         facet: FacetWithValuesFragment,
-        facetFormGroup: UntypedFormGroup,
+        facetFormGroup: (typeof this.detailForm)['controls']['facet'],
         languageCode: LanguageCode,
     ): CreateFacetInput | UpdateFacetInput {
         const input = createUpdatedTranslatable({
@@ -385,16 +400,16 @@ export class FacetDetailComponent
      */
     private getCreatedFacetValues(
         facet: FacetWithValuesFragment,
-        valuesFormArray: UntypedFormArray,
+        valuesFormArray: (typeof this.detailForm)['controls']['values'],
         languageCode: LanguageCode,
     ): CreateFacetValueInput[] {
         return valuesFormArray.controls
-            .filter(c => !c.value.id)
+            .filter(c => !c.value?.id)
             .map(c => c.value)
             .map(value =>
                 createUpdatedTranslatable({
                     translatable: { ...value, translations: [] as any },
-                    updatedFields: value,
+                    updatedFields: value ?? {},
                     customFieldConfig: this.customValueFields,
                     languageCode,
                     defaultTranslation: {
@@ -405,6 +420,7 @@ export class FacetDetailComponent
             )
             .map(input => ({
                 facetId: facet.id,
+                code: input.code ?? '',
                 ...input,
             }));
     }
@@ -430,7 +446,8 @@ export class FacetDetailComponent
             throw new Error(_(`error.facet-value-form-values-do-not-match`));
         }
         return dirtyValues
-            .map((value, i) => createUpdatedTranslatable({
+            .map((value, i) =>
+                createUpdatedTranslatable({
                     translatable: value,
                     updatedFields: dirtyValueValues[i],
                     customFieldConfig: this.customValueFields,
@@ -439,7 +456,8 @@ export class FacetDetailComponent
                         languageCode,
                         name: '',
                     },
-                }))
+                }),
+            )
             .filter(notNullOrUndefined);
     }
 }

+ 20 - 5
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.html

@@ -1,9 +1,24 @@
 <vdr-page-block>
-    <vdr-language-selector
-        [availableLanguageCodes]="availableLanguages$ | async"
-        [currentLanguageCode]="contentLanguage$ | async"
-        (languageCodeChange)="setLanguage($event)"
-    ></vdr-language-selector>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <vdr-language-selector
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="contentLanguage$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="facet-list"></vdr-action-bar-items>
+            <a
+                class="btn btn-primary"
+                [routerLink]="['./create']"
+                *vdrIfPermissions="['CreateCatalog', 'CreateFacet']"
+            >
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'catalog.create-new-facet' | translate }}
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
 </vdr-page-block>
 <vdr-data-table-2
     class="mt-2"

+ 28 - 51
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts

@@ -1,21 +1,27 @@
 import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    BaseListComponent,
     DataService,
-    DataTableService,
-    FacetFilterParameter,
-    FacetSortParameter,
+    FACET_WITH_VALUES_FRAGMENT,
+    GetFacetListDocument,
     GetFacetListQuery,
     ItemOf,
     LanguageCode,
-    ModalService,
-    NavBuilderService,
-    NotificationService,
-    ServerConfigService,
+    TypedBaseListComponent,
 } from '@vendure/admin-ui/core';
-import { Observable } from 'rxjs';
+import { gql } from 'apollo-angular';
+
+export const FACET_LIST_QUERY = gql`
+    query GetFacetList($options: FacetListOptions) {
+        facets(options: $options) {
+            items {
+                ...FacetWithValues
+            }
+            totalItems
+        }
+    }
+    ${FACET_WITH_VALUES_FRAGMENT}
+`;
 
 @Component({
     selector: 'vdr-facet-list',
@@ -23,17 +29,14 @@ import { Observable } from 'rxjs';
     styleUrls: ['./facet-list.component.scss'],
 })
 export class FacetListComponent
-    extends BaseListComponent<GetFacetListQuery, ItemOf<GetFacetListQuery, 'facets'>>
+    extends TypedBaseListComponent<typeof GetFacetListDocument, 'facets'>
     implements OnInit
 {
-    availableLanguages$: Observable<LanguageCode[]>;
-    contentLanguage$: Observable<LanguageCode>;
     readonly initialLimit = 3;
     displayLimit: { [id: string]: number } = {};
 
-    readonly customFields = this.serverConfigService.getCustomFieldsFor('Facet');
-    readonly filters = this.dataTableService
-        .createFilterCollection<FacetFilterParameter>()
+    readonly customFields = this.getCustomFieldConfig('Facet');
+    readonly filters = this.createFilterCollection()
         .addDateFilters()
         .addFilter({
             name: 'visibility',
@@ -46,8 +49,7 @@ export class FacetListComponent
         .addCustomFieldFilters(this.customFields)
         .connectToRoute(this.route);
 
-    readonly sorts = this.dataTableService
-        .createSortCollection<FacetSortParameter>()
+    readonly sorts = this.createSortCollection()
         .defaultSort('createdAt', 'DESC')
         .addSort({ name: 'id' })
         .addSort({ name: 'createdAt' })
@@ -57,29 +59,12 @@ export class FacetListComponent
         .addCustomFieldSorts(this.customFields)
         .connectToRoute(this.route);
 
-    constructor(
-        private dataService: DataService,
-        private modalService: ModalService,
-        private notificationService: NotificationService,
-        private serverConfigService: ServerConfigService,
-        private dataTableService: DataTableService,
-        navBuilderService: NavBuilderService,
-        router: Router,
-        route: ActivatedRoute,
-    ) {
-        super(router, route);
-        navBuilderService.addActionBarItem({
-            id: 'create-facet',
-            label: _('catalog.create-new-facet'),
-            locationId: 'facet-list',
-            icon: 'plus',
-            routerLink: ['./create'],
-            requiresPermission: ['CreateCatalog', 'CreateFacet'],
-        });
-        super.setQueryFn(
-            (...args: any[]) => this.dataService.facet.getFacets(...args).refetchOnChannelChange(),
-            data => data.facets,
-            (skip, take) => ({
+    constructor(protected dataService: DataService) {
+        super();
+        super.configure({
+            document: GetFacetListDocument,
+            getItems: data => data.facets,
+            setVariables: (skip, take) => ({
                 options: {
                     skip,
                     take,
@@ -92,16 +77,8 @@ export class FacetListComponent
                     sort: this.sorts.createSortInput(),
                 },
             }),
-        );
-    }
-
-    ngOnInit() {
-        super.ngOnInit();
-        this.availableLanguages$ = this.serverConfigService.getAvailableLanguages();
-        this.contentLanguage$ = this.dataService.client
-            .uiState()
-            .mapStream(({ uiState }) => uiState.contentLanguage);
-        super.refreshListOnChanges(this.filters.valueChanges, this.sorts.valueChanges, this.contentLanguage$);
+            refreshListOnChanges: [this.filters.valueChanges, this.sorts.valueChanges],
+        });
     }
 
     toggleDisplayLimit(facet: ItemOf<GetFacetListQuery, 'facets'>) {

+ 173 - 269
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html

@@ -1,285 +1,189 @@
-<vdr-action-bar>
-    <vdr-ab-left>
-        <div class="flex clr-flex-row">
-            <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-            <clr-toggle-wrapper *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']">
-                <input
-                    type="checkbox"
-                    clrToggle
-                    name="enabled"
-                    [formControl]="detailForm.get(['product', 'enabled'])"
-                />
-                <label>{{ 'common.enabled' | translate }}</label>
-            </clr-toggle-wrapper>
-        </div>
-        <vdr-language-selector
-            [disabled]="isNew$ | async"
-            [availableLanguageCodes]="availableLanguages$ | async"
-            [currentLanguageCode]="languageCode$ | async"
-            (languageCodeChange)="setLanguage($event)"
-        ></vdr-language-selector>
-    </vdr-ab-left>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <div class="flex clr-flex-row"></div>
+            <vdr-language-selector
+                [disabled]="isNew$ | async"
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="languageCode$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </vdr-ab-left>
 
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="product-detail"></vdr-action-bar-items>
-        <button
-            class="btn btn-primary"
-            *ngIf="isNew$ | async; else updateButton"
-            (click)="create()"
-            [disabled]="detailForm.invalid || detailForm.pristine || !variantsToCreateAreValid()"
-        >
-            {{ 'common.create' | translate }}
-        </button>
-        <ng-template #updateButton>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="product-detail"></vdr-action-bar-items>
             <button
-                *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
                 class="btn btn-primary"
-                (click)="save()"
-                [disabled]="
-                    (detailForm.invalid || detailForm.pristine) && !assetsChanged() && !variantAssetsChanged()
-                "
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="detailForm.invalid || detailForm.pristine"
             >
-                {{ 'common.update' | translate }}
+                {{ 'common.create' | translate }}
             </button>
-        </ng-template>
-    </vdr-ab-right>
-</vdr-action-bar>
+            <ng-template #updateButton>
+                <button
+                    *vdrIfPermissions="updatePermissions"
+                    class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="(detailForm.invalid || detailForm.pristine) && !assetsChanged()"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
 
-<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>
-            <button clrTabLink (click)="navigateToTab('details')">
-                {{ 'catalog.product-details' | translate }}
-            </button>
-            <clr-tab-content *clrIfActive="(activeTab$ | async) === 'details'">
-                <div class="clr-row">
-                    <div class="clr-col">
-                        <section class="form-block" formGroupName="product">
-                            <ng-container *ngIf="!(isNew$ | async)">
-                                <ng-container *vdrIfMultichannel>
-                                    <vdr-form-item
-                                        [label]="'common.channels' | translate"
-                                        *vdrIfDefaultChannelActive
-                                    >
-                                        <div class="flex channel-assignment">
-                                            <ng-container *ngFor="let channel of productChannels$ | async">
-                                                <vdr-chip
-                                                    *ngIf="!isDefaultChannel(channel.code)"
-                                                    icon="times-circle"
-                                                    (iconClick)="removeFromChannel(channel.id)"
-                                                >
-                                                    <vdr-channel-badge
-                                                        [channelCode]="channel.code"
-                                                    ></vdr-channel-badge>
-                                                    {{ channel.code | channelCodeToLabel }}
-                                                </vdr-chip>
-                                            </ng-container>
-                                            <button class="btn btn-sm" (click)="assignToChannel()">
-                                                <clr-icon shape="layers"></clr-icon>
-                                                {{ 'catalog.assign-to-channel' | translate }}
-                                            </button>
-                                        </div>
-                                    </vdr-form-item>
-                                </ng-container>
+<form class="form" [formGroup]="detailForm">
+    <vdr-page-detail-layout>
+        <vdr-page-detail-sidebar
+            ><vdr-card>
+                <vdr-form-field [label]="'catalog.visibility' | translate" for="visibility">
+                    <clr-toggle-wrapper *vdrIfPermissions="updatePermissions">
+                        <input
+                            type="checkbox"
+                            clrToggle
+                            name="enabled"
+                            [formControl]="detailForm.get(['enabled'])"
+                        />
+                        <label>{{ 'common.enabled' | translate }}</label>
+                    </clr-toggle-wrapper>
+                </vdr-form-field>
+            </vdr-card>
+            <ng-container *ngIf="!(isNew$ | async)">
+                <vdr-card *vdrIfMultichannel [title]="'common.channels' | translate">
+                    <vdr-form-item *vdrIfDefaultChannelActive>
+                        <div class="flex channel-assignment">
+                            <ng-container *ngFor="let channel of productChannels$ | async">
+                                <vdr-chip
+                                    *ngIf="!isDefaultChannel(channel.code)"
+                                    icon="times-circle"
+                                    (iconClick)="removeFromChannel(channel.id)"
+                                >
+                                    <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
+                                    {{ channel.code | channelCodeToLabel }}
+                                </vdr-chip>
                             </ng-container>
-                            <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
-                                <input
-                                    id="name"
-                                    type="text"
-                                    formControlName="name"
-                                    [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
-                                    (input)="updateSlug($event.target.value)"
-                                />
-                            </vdr-form-field>
-                            <div
-                                class="auto-rename-wrapper"
-                                [class.visible]="
-                                    (isNew$ | async) === false && detailForm.get(['product', 'name'])?.dirty
-                                "
-                            >
-                                <clr-checkbox-wrapper>
-                                    <input
-                                        clrCheckbox
-                                        type="checkbox"
-                                        id="auto-update"
-                                        formControlName="autoUpdateVariantNames"
-                                    />
-                                    <label>{{
-                                        'catalog.auto-update-product-variant-name' | translate
-                                    }}</label>
-                                </clr-checkbox-wrapper>
-                            </div>
-                            <vdr-form-field
-                                [label]="'catalog.slug' | translate"
-                                for="slug"
-                                [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
-                            >
-                                <input
-                                    id="slug"
-                                    type="text"
-                                    formControlName="slug"
-                                    [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
-                                />
-                            </vdr-form-field>
-                            <vdr-rich-text-editor
-                                formControlName="description"
-                                [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
-                                [label]="'common.description' | translate"
-                            ></vdr-rich-text-editor>
-
-                            <section formGroupName="customFields" *ngIf="customFields.length">
-                                <label>{{ 'common.custom-fields' | translate }}</label>
-                                <vdr-tabbed-custom-fields
-                                    entityName="Product"
-                                    [customFields]="customFields"
-                                    [customFieldsFormGroup]="detailForm.get(['product', 'customFields'])"
-                                    [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
-                                ></vdr-tabbed-custom-fields>
-                            </section>
-                            <vdr-custom-detail-component-host
-                                locationId="product-detail"
-                                [entity$]="entity$"
-                                [detailForm]="detailForm"
-                            ></vdr-custom-detail-component-host>
-                        </section>
-                    </div>
-                    <div class="clr-col-md-auto">
-                        <vdr-assets
-                            [assets]="assetChanges.assets || product.assets"
-                            [featuredAsset]="assetChanges.featuredAsset || product.featuredAsset"
-                            [updatePermissions]="updatePermissions"
-                            (change)="assetChanges = $event"
-                        ></vdr-assets>
-                        <div class="facets">
-                            <vdr-facet-value-chip
-                                *ngFor="let facetValue of facetValues$ | async"
-                                [facetValue]="facetValue"
-                                [removable]="['UpdateCatalog', 'UpdateProduct'] | hasPermission"
-                                (remove)="removeProductFacetValue(facetValue.id)"
-                            ></vdr-facet-value-chip>
-                            <button
-                                class="btn btn-sm btn-secondary"
-                                *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
-                                (click)="selectProductFacetValue()"
-                            >
-                                <clr-icon shape="plus"></clr-icon>
-                                {{ 'catalog.add-facets' | translate }}
+                            <button class="btn btn-sm" (click)="assignToChannel()">
+                                <clr-icon shape="layers"></clr-icon>
+                                {{ 'catalog.assign-to-channel' | translate }}
                             </button>
                         </div>
-                    </div>
+                    </vdr-form-item>
+                </vdr-card>
+            </ng-container>
+            <vdr-card [title]="'catalog.facets' | translate">
+                <div class="facets">
+                    <vdr-facet-value-chip
+                        *ngFor="let facetValue of facetValues$ | async"
+                        [facetValue]="facetValue"
+                        [removable]="updatePermissions | hasPermission"
+                        (remove)="removeProductFacetValue(facetValue.id)"
+                    ></vdr-facet-value-chip>
+                    <button
+                        class="btn btn-sm btn-secondary"
+                        *vdrIfPermissions="updatePermissions"
+                        (click)="selectProductFacetValue()"
+                    >
+                        <clr-icon shape="plus"></clr-icon>
+                        {{ 'catalog.add-facets' | translate }}
+                    </button>
                 </div>
+            </vdr-card>
 
-                <div *ngIf="isNew$ | async">
-                    <h4>{{ 'catalog.product-variants' | translate }}</h4>
-                    <vdr-generate-product-variants
-                        (variantsChange)="createVariantsConfig = $event"
-                    ></vdr-generate-product-variants>
-                </div>
-            </clr-tab-content>
-        </clr-tab>
-        <clr-tab *ngIf="!(isNew$ | async)">
-            <button clrTabLink (click)="navigateToTab('variants')">
-                {{ 'catalog.product-variants' | translate }}
-            </button>
-            <clr-tab-content *clrIfActive="(activeTab$ | async) === 'variants'">
-                <section class="form-block">
-                    <div class="view-mode">
-                        <div class="btn-group">
-                            <button
-                                class="btn btn-secondary-outline"
-                                (click)="variantDisplayMode = 'card'"
-                                [class.btn-primary]="variantDisplayMode === 'card'"
-                            >
-                                <clr-icon shape="list"></clr-icon>
-                                <span class="full-label">{{ 'catalog.display-variant-cards' | translate }}</span>
-                            </button>
-                            <button
-                                class="btn"
-                                (click)="variantDisplayMode = 'table'"
-                                [class.btn-primary]="variantDisplayMode === 'table'"
-                            >
-                                <clr-icon shape="table"></clr-icon>
-                                <span class="full-label">{{ 'catalog.display-variant-table' | translate }}</span>
-                            </button>
-                        </div>
-                        <div class="variant-filter">
-                            <input
-                                [formControl]="filterInput"
-                                [placeholder]="'catalog.filter-by-name-or-sku' | translate"
-                            />
-                            <button class="icon-button" (click)="filterInput.setValue('')">
-                                <clr-icon shape="times"></clr-icon>
-                            </button>
-                        </div>
-                        <div class="flex-spacer"></div>
-                        <a
-                            *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
-                            [routerLink]="['./', 'manage-variants']"
-                            class="btn btn-secondary edit-variants-btn mb0 mr0"
-                        >
-                            <clr-icon shape="add-text"></clr-icon>
-                            {{ 'catalog.manage-variants' | translate }}
-                        </a>
-                    </div>
+            <vdr-card>
+                <vdr-page-entity-info
+                    *ngIf="entity$ | async as entity"
+                    [entity]="entity"
+                ></vdr-page-entity-info>
+            </vdr-card>
+        </vdr-page-detail-sidebar>
 
-                    <div class="pagination-row mt-4" *ngIf="10 < (paginationConfig$ | async)?.totalItems">
-                        <vdr-items-per-page-controls
-                            [itemsPerPage]="itemsPerPage$ | async"
-                            (itemsPerPageChange)="setItemsPerPage($event)"
-                        ></vdr-items-per-page-controls>
+        <vdr-page-block>
+            <button type="submit" hidden x-data="prevents enter key from triggering other buttons"></button>
+            <vdr-card>
+                <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
+                    <input
+                        id="name"
+                        type="text"
+                        formControlName="name"
+                        [readonly]="!(updatePermissions | hasPermission)"
+                        (input)="updateSlug($event.target.value)"
+                    />
+                </vdr-form-field>
+                <div *ngIf="(isNew$ | async) === false && detailForm.get(['name'])?.dirty">
+                    <clr-checkbox-wrapper>
+                        <input
+                            clrCheckbox
+                            type="checkbox"
+                            id="auto-update"
+                            formControlName="autoUpdateVariantNames"
+                        />
+                        <label>{{ 'catalog.auto-update-product-variant-name' | translate }}</label>
+                    </clr-checkbox-wrapper>
+                </div>
+                <vdr-form-field
+                    [label]="'catalog.slug' | translate"
+                    for="slug"
+                    [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
+                >
+                    <input
+                        id="slug"
+                        type="text"
+                        formControlName="slug"
+                        [readonly]="!(updatePermissions | hasPermission)"
+                    />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'common.description' | translate"
+                    for="slug"
+                    [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
+                    class="card-span"
+                >
+                    <vdr-rich-text-editor
+                        formControlName="description"
+                        [readonly]="!(updatePermissions | hasPermission)"
+                    ></vdr-rich-text-editor>
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card [title]="'common.custom-fields' | translate" *ngIf="customFields.length">
+                <vdr-tabbed-custom-fields
+                    entityName="Product"
+                    [customFields]="customFields"
+                    [customFieldsFormGroup]="detailForm.get(['customFields'])"
+                    [readonly]="!(updatePermissions | hasPermission)"
+                ></vdr-tabbed-custom-fields>
+            </vdr-card>
+            <vdr-custom-detail-component-host
+                locationId="product-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
+            <vdr-card [title]="'catalog.assets' | translate">
+                <vdr-assets
+                    class="card-span"
+                    [assets]="assetChanges.assets || entity?.assets"
+                    [featuredAsset]="assetChanges.featuredAsset || entity?.featuredAsset"
+                    [updatePermissions]="updatePermissions"
+                    (change)="assetChanges = $event"
+                ></vdr-assets>
+            </vdr-card>
 
-                        <vdr-pagination-controls
-                            [id]="(paginationConfig$ | async)?.id"
-                            [currentPage]="currentPage$ | async"
-                            [itemsPerPage]="itemsPerPage$ | async"
-                            (pageChange)="setPage($event)"
-                        ></vdr-pagination-controls>
+            <vdr-card [title]="'catalog.product-variants' | translate" [paddingX]="false">
+                <div class="card-span">
+                    <div *ngIf="isNew$ | async; else variantList">
+                        <vdr-generate-product-variants
+                            (variantsChange)="createVariantsConfig = $event"
+                        ></vdr-generate-product-variants>
                     </div>
-
-                    <vdr-product-variants-table
-                        *ngIf="variantDisplayMode === 'table'"
-                        [variants]="variants$ | async"
-                        [paginationConfig]="paginationConfig$ | async"
-                        [optionGroups]="product.optionGroups"
-                        [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
-                        [productVariantsFormArray]="detailForm.get('variants')"
-                        [pendingAssetChanges]="variantAssetChanges"
-                    ></vdr-product-variants-table>
-                    <vdr-product-variants-list
-                        *ngIf="variantDisplayMode === 'card'"
-                        [variants]="variants$ | async"
-                        [paginationConfig]="paginationConfig$ | async"
-                        [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
-                        [pendingFacetValueChanges]="variantFacetValueChanges"
-                        [optionGroups]="product.optionGroups"
-                        [productVariantsFormArray]="detailForm.get('variants')"
-                        [taxCategories]="taxCategories$ | async"
-                        [customFields]="customVariantFields"
-                        [customOptionFields]="customOptionFields"
-                        [activeLanguage]="languageCode$ | async"
-                        [pendingAssetChanges]="variantAssetChanges"
-                        (assignToChannel)="assignVariantToChannel($event)"
-                        (removeFromChannel)="removeVariantFromChannel($event)"
-                        (assetChange)="variantAssetChange($event)"
-                        (updateProductOption)="updateProductOption($event)"
-                        (selectionChange)="selectedVariantIds = $event"
-                    ></vdr-product-variants-list>
-                </section>
-                <div class="pagination-row mt-4" *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>
+                    <ng-template #variantList>
+                        <vdr-product-variant-list
+                            [productId]="this.id"
+                            [hideLanguageSelect]="true"
+                        ></vdr-product-variant-list>
+                    </ng-template>
                 </div>
-            </clr-tab-content>
-        </clr-tab>
-    </clr-tabs>
+            </vdr-card>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
 </form>

+ 1 - 18
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -9,13 +9,9 @@
 }
 
 .facets {
-    margin-top: 12px;
-    @media screen and (min-width: $breakpoint-small) {
-        max-width: 340px;
-    }
     display: flex;
     flex-wrap: wrap;
-    align-items: center;
+    gap: 3px;
 }
 
 vdr-action-bar clr-toggle-wrapper {
@@ -58,19 +54,6 @@ vdr-action-bar clr-toggle-wrapper {
 .channel-assignment {
     flex-wrap: wrap;
     max-height: 144px;
-    overflow-y: auto;
-}
-
-.auto-rename-wrapper {
-    overflow: hidden;
-    max-height: 0;
-    padding-left: 9.5rem;
-    margin-bottom: 0;
-    transition: max-height 0.2s, margin-bottom 0.2s;
-    &.visible {
-        max-height: 24px;
-        margin-bottom: 12px;
-    }
 }
 
 .pagination-row {

+ 87 - 329
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -1,32 +1,29 @@
-import { Location } from '@angular/common';
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import {
-    UntypedFormArray,
-    UntypedFormBuilder,
-    UntypedFormControl,
-    UntypedFormGroup,
-    Validators,
-} from '@angular/forms';
+import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    Asset,
     BaseDetailComponent,
     CreateProductInput,
     createUpdatedTranslatable,
-    CustomFieldConfig,
     DataService,
     findTranslation,
     getChannelCodeFromUserStatus,
+    getDefaultUiLanguage,
+    GetProductDetailDocument,
+    GetProductDetailQuery,
     GetProductWithVariantsQuery,
+    ItemOf,
     LanguageCode,
-    LogicalOperator,
     ModalService,
     NotificationService,
     Permission,
+    PRODUCT_DETAIL_FRAGMENT,
     ProductDetailFragment,
     ProductVariantFragment,
     ServerConfigService,
-    TaxCategoryFragment,
+    TypedBaseDetailComponent,
     unicodePatternValidator,
     UpdateProductInput,
     UpdateProductMutation,
@@ -36,69 +33,65 @@ import {
 } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
-import { BehaviorSubject, combineLatest, concat, EMPTY, from, merge, Observable } from 'rxjs';
+import { gql } from 'apollo-angular';
+import { combineLatest, concat, EMPTY, from, Observable } from 'rxjs';
 import {
-    debounceTime,
     distinctUntilChanged,
     map,
     mergeMap,
     shareReplay,
     skip,
-    skipUntil,
-    startWith,
     switchMap,
     switchMapTo,
     take,
-    takeUntil,
-    tap,
 } from 'rxjs/operators';
 
 import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
 import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
 import { AssignProductsToChannelDialogComponent } from '../assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { CreateProductVariantsConfig } from '../generate-product-variants/generate-product-variants.component';
-import { VariantAssetChange } from '../product-variants-list/product-variants-list.component';
 
-import { PaginationConfig, SelectedAssets, TabName, VariantFormValue } from './product-detail.types';
+interface SelectedAssets {
+    assets?: Asset[];
+    featuredAsset?: Asset;
+}
+
+export const GET_PRODUCT_DETAIL = gql`
+    query GetProductDetail($id: ID!) {
+        product(id: $id) {
+            ...ProductDetail
+        }
+    }
+    ${PRODUCT_DETAIL_FRAGMENT}
+`;
 
 @Component({
-    selector: 'vdr-product-detail',
+    selector: 'vdr-product-detail2',
     templateUrl: './product-detail.component.html',
     styleUrls: ['./product-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ProductDetailComponent
-    extends BaseDetailComponent<NonNullable<GetProductWithVariantsQuery['product']>>
+    extends TypedBaseDetailComponent<typeof GetProductDetailDocument, 'product'>
     implements OnInit, OnDestroy
 {
-    activeTab$: Observable<TabName>;
-    product$: Observable<NonNullable<GetProductWithVariantsQuery['product']>>;
-    variants$: Observable<ProductVariantFragment[]>;
-    taxCategories$: Observable<TaxCategoryFragment[]>;
-    customFields: CustomFieldConfig[];
-    customVariantFields: CustomFieldConfig[];
-    customOptionGroupFields: CustomFieldConfig[];
-    customOptionFields: CustomFieldConfig[];
-    detailForm: UntypedFormGroup;
-    filterInput = new UntypedFormControl('');
+    readonly customFields = this.getCustomFieldConfig('Product');
+    detailForm = this.formBuilder.group({
+        enabled: true,
+        name: ['', Validators.required],
+        autoUpdateVariantNames: true,
+        slug: ['', unicodePatternValidator(/^[\p{Letter}0-9_-]+$/)],
+        description: '',
+        facetValueIds: [[] as string[]],
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
     assetChanges: SelectedAssets = {};
-    variantAssetChanges: { [variantId: string]: SelectedAssets } = {};
-    variantFacetValueChanges: { [variantId: string]: ProductVariantFragment['facetValues'] } = {};
     productChannels$: Observable<ProductDetailFragment['channels']>;
     facetValues$: Observable<ProductDetailFragment['facetValues']>;
-    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, ProductVariantFragment>();
     public readonly updatePermissions = [Permission.UpdateCatalog, Permission.UpdateProduct];
 
     constructor(
@@ -106,95 +99,19 @@ export class ProductDetailComponent
         router: Router,
         serverConfigService: ServerConfigService,
         private productDetailService: ProductDetailService,
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         private modalService: ModalService,
         private notificationService: NotificationService,
         protected dataService: DataService,
-        private location: Location,
         private changeDetector: ChangeDetectorRef,
     ) {
-        super(route, router, serverConfigService, dataService);
-        this.customFields = this.getCustomFieldConfig('Product');
-        this.customVariantFields = this.getCustomFieldConfig('ProductVariant');
-        this.customOptionGroupFields = this.getCustomFieldConfig('ProductOptionGroup');
-        this.customOptionFields = this.getCustomFieldConfig('ProductOption');
-        this.detailForm = this.formBuilder.group({
-            product: this.formBuilder.group({
-                enabled: true,
-                name: ['', Validators.required],
-                autoUpdateVariantNames: true,
-                slug: ['', unicodePatternValidator(/^[\p{Letter}0-9_-]+$/)],
-                description: '',
-                facetValueIds: [[]],
-                customFields: this.formBuilder.group(
-                    this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-                ),
-            }),
-            variants: this.formBuilder.array([]),
-        });
+        super();
     }
 
     ngOnInit() {
         this.init();
-        this.product$ = this.entity$;
-        const filterTerm$ = this.filterInput.valueChanges.pipe(
-            startWith(''),
-            debounceTime(200),
-            shareReplay(),
-            tap(() => this.currentPage$.next(1)),
-        );
-        const initialVariants$ = this.product$.pipe(map(p => p.variantList.items));
-        const variantsList$ = combineLatest(filterTerm$, this.currentPage$, this.itemsPerPage$).pipe(
-            skipUntil(initialVariants$),
-            skip(1),
-            debounceTime(100),
-            switchMap(([term, currentPage, itemsPerPage]) =>
-                this.dataService.product
-                    .getProductVariantsForProduct(
-                        {
-                            skip: (currentPage - 1) * itemsPerPage,
-                            take: itemsPerPage,
-                            ...(term
-                                ? { filter: { name: { contains: term }, sku: { contains: term } } }
-                                : {}),
-                            filterOperator: LogicalOperator.OR,
-                        },
-                        this.id,
-                    )
-                    .mapStream(({ productVariants }) => productVariants),
-            ),
-            shareReplay({ bufferSize: 1, refCount: true }),
-        );
-        const updatedVariants$ = variantsList$.pipe(map(result => result.items));
-        this.variants$ = merge(initialVariants$, updatedVariants$).pipe(
-            tap(variants => {
-                for (const variant of variants) {
-                    this.productVariantMap.set(variant.id, variant);
-                }
-            }),
-        );
-        this.totalItems$ = merge(
-            this.product$.pipe(map(product => product.variantList.totalItems)),
-            variantsList$.pipe(map(result => result.totalItems)),
-        );
-        this.paginationConfig$ = combineLatest(this.totalItems$, this.itemsPerPage$, this.currentPage$).pipe(
-            map(([totalItems, itemsPerPage, currentPage]) => ({
-                totalItems,
-                itemsPerPage,
-                currentPage,
-            })),
-        );
-        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);
-            });
-
-        const productFacetValues$ = this.product$.pipe(map(product => product.facetValues));
-        const productGroup = this.getProductFormGroup();
+        const productFacetValues$ = this.entity$.pipe(map(product => product.facetValues));
+        const productGroup = this.detailForm;
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const formFacetValueIdChanges$ = productGroup.get('facetValueIds')!.valueChanges.pipe(
             skip(1),
@@ -210,42 +127,17 @@ export class ProductDetailComponent
             productFacetValues$.pipe(take(1)),
             productFacetValues$.pipe(switchMapTo(formFacetValueIdChanges$)),
         );
-        this.productChannels$ = this.product$.pipe(map(p => p.channels));
-        this.channelPriceIncludesTax$ = this.dataService.settings
-            .getActiveChannel('cache-first')
-            .refetchOnChannelChange()
-            .mapStream(data => data.activeChannel.pricesIncludeTax)
-            .pipe(shareReplay(1));
+        this.productChannels$ = this.entity$.pipe(map(p => p.channels));
     }
 
     ngOnDestroy() {
         this.destroy();
     }
 
-    navigateToTab(tabName: TabName) {
-        this.location.replaceState(
-            this.router
-                .createUrlTree(['./', { ...this.route.snapshot.params, tab: tabName }], {
-                    queryParamsHandling: 'merge',
-                    relativeTo: this.route,
-                })
-                .toString(),
-        );
-    }
-
     isDefaultChannel(channelCode: string): boolean {
         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(
@@ -352,35 +244,23 @@ export class ProductDetailComponent
         return !!Object.values(this.assetChanges).length;
     }
 
-    variantAssetsChanged(): boolean {
-        return !!Object.keys(this.variantAssetChanges).length;
-    }
-
-    variantAssetChange(event: VariantAssetChange) {
-        this.variantAssetChanges[event.variantId] = event;
-    }
-
     /**
      * If creating a new product, automatically generate the slug based on the product name.
      */
     updateSlug(nameValue: string) {
-        combineLatest(this.entity$, this.languageCode$)
-            .pipe(take(1))
-            .subscribe(([entity, languageCode]) => {
-                const slugControl = this.detailForm.get(['product', 'slug']);
-                const currentTranslation = findTranslation(entity, languageCode);
-                const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
-                if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
-                    slugControl.setValue(normalizeString(`${nameValue}`, '-'));
-                }
-            });
+        const slugControl = this.detailForm.get('slug');
+        const currentTranslation = this.entity ? findTranslation(this.entity, this.languageCode) : undefined;
+        const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
+        if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
+            slugControl.setValue(normalizeString(`${nameValue}`, '-'));
+        }
     }
 
     selectProductFacetValue() {
         this.displayFacetValueModal().subscribe(facetValueIds => {
             if (facetValueIds) {
-                const productGroup = this.getProductFormGroup();
-                const currentFacetValueIds = productGroup.value.facetValueIds;
+                const productGroup = this.detailForm;
+                const currentFacetValueIds = productGroup.value.facetValueIds ?? [];
                 productGroup.patchValue({
                     facetValueIds: unique([...currentFacetValueIds, ...facetValueIds]),
                 });
@@ -390,7 +270,7 @@ export class ProductDetailComponent
     }
 
     updateProductOption(input: UpdateProductOptionInput & { autoUpdate: boolean }) {
-        combineLatest(this.product$, this.languageCode$)
+        combineLatest(this.entity$, this.languageCode$)
             .pipe(
                 take(1),
                 mergeMap(([product, languageCode]) =>
@@ -412,21 +292,14 @@ export class ProductDetailComponent
     }
 
     removeProductFacetValue(facetValueId: string) {
-        const productGroup = this.getProductFormGroup();
-        const currentFacetValueIds = productGroup.value.facetValueIds;
+        const productGroup = this.detailForm;
+        const currentFacetValueIds = productGroup.value.facetValueIds ?? [];
         productGroup.patchValue({
             facetValueIds: currentFacetValueIds.filter(id => id !== facetValueId),
         });
         productGroup.markAsDirty();
     }
 
-    variantsToCreateAreValid(): boolean {
-        return (
-            0 < this.createVariantsConfig.variants.length &&
-            this.createVariantsConfig.variants.every(v => v.sku !== '')
-        );
-    }
-
     private displayFacetValueModal(): Observable<string[] | undefined> {
         return this.modalService
             .fromComponent(ApplyFacetDialogComponent, {
@@ -437,33 +310,39 @@ export class ProductDetailComponent
     }
 
     create() {
-        const productGroup = this.getProductFormGroup();
+        const productGroup = this.detailForm;
         if (!productGroup.dirty) {
             return;
         }
-        combineLatest(this.product$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([product, languageCode]) => {
-                    const newProduct = this.getUpdatedProduct(
-                        product,
-                        productGroup as UntypedFormGroup,
-                        languageCode,
-                    ) as CreateProductInput;
-                    return this.productDetailService.createProductWithVariants(
-                        newProduct,
-                        this.createVariantsConfig,
-                        languageCode,
-                    );
-                }),
-            )
+
+        const newProduct = this.getUpdatedProduct(
+            {
+                id: '',
+                createdAt: '',
+                updatedAt: '',
+                enabled: true,
+                languageCode: this.languageCode,
+                name: '',
+                slug: '',
+                featuredAsset: null,
+                assets: [],
+                description: '',
+                translations: [],
+                optionGroups: [],
+                facetValues: [],
+                channels: [],
+            },
+            productGroup as UntypedFormGroup,
+            this.languageCode,
+        ) as CreateProductInput;
+        this.productDetailService
+            .createProductWithVariants(newProduct, this.createVariantsConfig, this.languageCode)
             .subscribe(
                 ({ createProductVariants, productId }) => {
                     this.notificationService.success(_('common.notify-create-success'), {
                         entity: 'Product',
                     });
                     this.assetChanges = {};
-                    this.variantAssetChanges = {};
                     this.detailForm.markAsPristine();
                     this.router.navigate(['../', productId], { relativeTo: this.route });
                 },
@@ -478,11 +357,11 @@ export class ProductDetailComponent
     }
 
     save() {
-        combineLatest(this.product$, this.languageCode$, this.channelPriceIncludesTax$)
+        combineLatest(this.entity$, this.languageCode$)
             .pipe(
                 take(1),
-                mergeMap(([product, languageCode, priceIncludesTax]) => {
-                    const productGroup = this.getProductFormGroup();
+                mergeMap(([product, languageCode]) => {
+                    const productGroup = this.detailForm;
                     let productInput: UpdateProductInput | undefined;
                     let variantsInput: UpdateProductVariantInput[] | undefined;
 
@@ -493,15 +372,6 @@ export class ProductDetailComponent
                             languageCode,
                         ) as UpdateProductInput;
                     }
-                    const variantsArray = this.detailForm.get('variants');
-                    if ((variantsArray && variantsArray.dirty) || this.variantAssetsChanged()) {
-                        variantsInput = this.getUpdatedProductVariants(
-                            product,
-                            variantsArray as UntypedFormArray,
-                            languageCode,
-                            priceIncludesTax,
-                        );
-                    }
 
                     return this.productDetailService.updateProduct({
                         product,
@@ -518,8 +388,6 @@ export class ProductDetailComponent
                     this.updateSlugAfterSave(result);
                     this.detailForm.markAsPristine();
                     this.assetChanges = {};
-                    this.variantAssetChanges = {};
-                    this.variantFacetValueChanges = {};
                     this.notificationService.success(_('common.notify-update-success'), {
                         entity: 'Product',
                     });
@@ -546,82 +414,21 @@ export class ProductDetailComponent
     ) {
         const currentTranslation = findTranslation(product, languageCode);
         this.detailForm.patchValue({
-            product: {
-                enabled: product.enabled,
-                name: currentTranslation ? currentTranslation.name : '',
-                slug: currentTranslation ? currentTranslation.slug : '',
-                description: currentTranslation ? currentTranslation.description : '',
-                facetValueIds: product.facetValues.map(fv => fv.id),
-            },
+            enabled: product.enabled,
+            name: currentTranslation ? currentTranslation.name : '',
+            slug: currentTranslation ? currentTranslation.slug : '',
+            description: currentTranslation ? currentTranslation.description : '',
+            facetValueIds: product.facetValues.map(fv => fv.id),
         });
 
         if (this.customFields.length) {
             this.setCustomFieldFormValues(
                 this.customFields,
-                this.detailForm.get(['product', 'customFields']),
+                this.detailForm.get(['customFields']),
                 product,
                 currentTranslation,
             );
         }
-        this.buildVariantFormArray(product.variantList.items, languageCode);
-    }
-
-    private buildVariantFormArray(variants: ProductVariantFragment[], languageCode: LanguageCode) {
-        const variantsFormArray = this.detailForm.get('variants') as UntypedFormArray;
-        variants.forEach((variant, i) => {
-            const variantTranslation = findTranslation(variant, languageCode);
-            const pendingFacetValueChanges = this.variantFacetValueChanges[variant.id];
-            const facetValueIds = pendingFacetValueChanges
-                ? pendingFacetValueChanges.map(fv => fv.id)
-                : variant.facetValues.map(fv => fv.id);
-            const group: VariantFormValue = {
-                id: variant.id,
-                enabled: variant.enabled,
-                sku: variant.sku,
-                name: variantTranslation ? variantTranslation.name : '',
-                price: variant.price,
-                priceWithTax: variant.priceWithTax,
-                taxCategoryId: variant.taxCategory.id,
-                stockOnHand: variant.stockOnHand,
-                useGlobalOutOfStockThreshold: variant.useGlobalOutOfStockThreshold,
-                outOfStockThreshold: variant.outOfStockThreshold,
-                trackInventory: variant.trackInventory,
-                facetValueIds,
-            };
-
-            let variantFormGroup = variantsFormArray.controls.find(c => c.value.id === variant.id) as
-                | UntypedFormGroup
-                | undefined;
-            if (variantFormGroup) {
-                if (variantFormGroup.pristine) {
-                    variantFormGroup.patchValue(group);
-                }
-            } else {
-                variantFormGroup = this.formBuilder.group({
-                    ...group,
-                    facetValueIds: this.formBuilder.control(facetValueIds),
-                });
-                variantsFormArray.insert(i, variantFormGroup);
-            }
-            if (this.customVariantFields.length) {
-                let customFieldsGroup = variantFormGroup.get(['customFields']) as
-                    | UntypedFormGroup
-                    | undefined;
-
-                if (!customFieldsGroup) {
-                    customFieldsGroup = this.formBuilder.group(
-                        this.customVariantFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-                    );
-                    variantFormGroup.addControl('customFields', customFieldsGroup);
-                }
-                this.setCustomFieldFormValues(
-                    this.customVariantFields,
-                    customFieldsGroup,
-                    variant,
-                    variantTranslation,
-                );
-            }
-        });
     }
 
     /**
@@ -629,7 +436,7 @@ export class ProductDetailComponent
      * can then be persisted to the API.
      */
     private getUpdatedProduct(
-        product: NonNullable<GetProductWithVariantsQuery['product']>,
+        product: NonNullable<GetProductDetailQuery['product']>,
         productFormGroup: UntypedFormGroup,
         languageCode: LanguageCode,
     ): UpdateProductInput | CreateProductInput {
@@ -653,55 +460,6 @@ export class ProductDetailComponent
         } as UpdateProductInput | CreateProductInput;
     }
 
-    /**
-     * Given an array of product variants and the values from the detailForm, this method creates an new array
-     * which can be persisted to the API.
-     */
-    private getUpdatedProductVariants(
-        product: NonNullable<GetProductWithVariantsQuery['product']>,
-        variantsFormArray: UntypedFormArray,
-        languageCode: LanguageCode,
-        priceIncludesTax: boolean,
-    ): UpdateProductVariantInput[] {
-        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.find(value => value.id === variant.id);
-                const result: UpdateProductVariantInput = createUpdatedTranslatable({
-                    translatable: variant,
-                    updatedFields: formValue,
-                    customFieldConfig: this.customVariantFields,
-                    languageCode,
-                    defaultTranslation: {
-                        languageCode,
-                        name: '',
-                    },
-                });
-                result.taxCategoryId = formValue.taxCategoryId;
-                result.facetValueIds = formValue.facetValueIds;
-                result.price = priceIncludesTax ? formValue.priceWithTax : formValue.price;
-                const assetChanges = this.variantAssetChanges[variant.id];
-                if (assetChanges) {
-                    result.featuredAssetId = assetChanges.featuredAsset?.id;
-                    result.assetIds = assetChanges.assets?.map(a => a.id);
-                }
-                return result;
-            })
-            .filter(notNullOrUndefined);
-    }
-
-    private getProductFormGroup(): UntypedFormGroup {
-        return this.detailForm.get('product') as UntypedFormGroup;
-    }
-
     /**
      * The server may alter the slug value in order to normalize and ensure uniqueness upon saving.
      */

+ 0 - 30
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.types.ts

@@ -1,30 +0,0 @@
-import { Asset, GlobalFlag } from '@vendure/admin-ui/core';
-
-export type TabName = 'details' | 'variants';
-
-export interface VariantFormValue {
-    id: string;
-    enabled: boolean;
-    sku: string;
-    name: string;
-    price: number;
-    priceWithTax: number;
-    taxCategoryId: string;
-    stockOnHand: number;
-    useGlobalOutOfStockThreshold: boolean;
-    outOfStockThreshold: number;
-    trackInventory: GlobalFlag;
-    facetValueIds: string[];
-    customFields?: any;
-}
-
-export interface SelectedAssets {
-    assets?: Asset[];
-    featuredAsset?: Asset;
-}
-
-export interface PaginationConfig {
-    totalItems: number;
-    currentPage: number;
-    itemsPerPage: number;
-}

+ 0 - 189
packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.html

@@ -1,189 +0,0 @@
-<vdr-page-block>
-    <vdr-action-bar>
-        <vdr-ab-left>
-            <div class="flex clr-flex-row"></div>
-            <vdr-language-selector
-                [disabled]="isNew$ | async"
-                [availableLanguageCodes]="availableLanguages$ | async"
-                [currentLanguageCode]="languageCode$ | async"
-                (languageCodeChange)="setLanguage($event)"
-            ></vdr-language-selector>
-        </vdr-ab-left>
-
-        <vdr-ab-right>
-            <vdr-action-bar-items locationId="product-detail"></vdr-action-bar-items>
-            <button
-                class="btn btn-primary"
-                *ngIf="isNew$ | async; else updateButton"
-                (click)="create()"
-                [disabled]="detailForm.invalid || detailForm.pristine"
-            >
-                {{ 'common.create' | translate }}
-            </button>
-            <ng-template #updateButton>
-                <button
-                    *vdrIfPermissions="updatePermissions"
-                    class="btn btn-primary"
-                    (click)="save()"
-                    [disabled]="(detailForm.invalid || detailForm.pristine) && !assetsChanged()"
-                >
-                    {{ 'common.update' | translate }}
-                </button>
-            </ng-template>
-        </vdr-ab-right>
-    </vdr-action-bar>
-</vdr-page-block>
-
-<form class="form" [formGroup]="detailForm" *ngIf="product$ | async as product">
-    <vdr-page-detail-layout>
-        <vdr-page-detail-sidebar
-            ><vdr-card>
-                <vdr-form-field [label]="'catalog.visibility' | translate" for="visibility">
-                    <clr-toggle-wrapper *vdrIfPermissions="updatePermissions">
-                        <input
-                            type="checkbox"
-                            clrToggle
-                            name="enabled"
-                            [formControl]="detailForm.get(['enabled'])"
-                        />
-                        <label>{{ 'common.enabled' | translate }}</label>
-                    </clr-toggle-wrapper>
-                </vdr-form-field>
-            </vdr-card>
-            <ng-container *ngIf="!(isNew$ | async)">
-                <vdr-card *vdrIfMultichannel [title]="'common.channels' | translate">
-                    <vdr-form-item *vdrIfDefaultChannelActive>
-                        <div class="flex channel-assignment">
-                            <ng-container *ngFor="let channel of productChannels$ | async">
-                                <vdr-chip
-                                    *ngIf="!isDefaultChannel(channel.code)"
-                                    icon="times-circle"
-                                    (iconClick)="removeFromChannel(channel.id)"
-                                >
-                                    <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
-                                    {{ channel.code | channelCodeToLabel }}
-                                </vdr-chip>
-                            </ng-container>
-                            <button class="btn btn-sm" (click)="assignToChannel()">
-                                <clr-icon shape="layers"></clr-icon>
-                                {{ 'catalog.assign-to-channel' | translate }}
-                            </button>
-                        </div>
-                    </vdr-form-item>
-                </vdr-card>
-            </ng-container>
-            <vdr-card [title]="'catalog.facets' | translate">
-                <div class="facets">
-                    <vdr-facet-value-chip
-                        *ngFor="let facetValue of facetValues$ | async"
-                        [facetValue]="facetValue"
-                        [removable]="updatePermissions | hasPermission"
-                        (remove)="removeProductFacetValue(facetValue.id)"
-                    ></vdr-facet-value-chip>
-                    <button
-                        class="btn btn-sm btn-secondary"
-                        *vdrIfPermissions="updatePermissions"
-                        (click)="selectProductFacetValue()"
-                    >
-                        <clr-icon shape="plus"></clr-icon>
-                        {{ 'catalog.add-facets' | translate }}
-                    </button>
-                </div>
-            </vdr-card>
-
-            <vdr-card>
-                <vdr-page-entity-info
-                    *ngIf="entity$ | async as entity"
-                    [entity]="entity"
-                ></vdr-page-entity-info>
-            </vdr-card>
-        </vdr-page-detail-sidebar>
-
-        <vdr-page-block>
-            <button type="submit" hidden x-data="prevents enter key from triggering other buttons"></button>
-            <vdr-card>
-                <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
-                    <input
-                        id="name"
-                        type="text"
-                        formControlName="name"
-                        [readonly]="!(updatePermissions | hasPermission)"
-                        (input)="updateSlug($event.target.value)"
-                    />
-                </vdr-form-field>
-                <div *ngIf="(isNew$ | async) === false && detailForm.get(['name'])?.dirty">
-                    <clr-checkbox-wrapper>
-                        <input
-                            clrCheckbox
-                            type="checkbox"
-                            id="auto-update"
-                            formControlName="autoUpdateVariantNames"
-                        />
-                        <label>{{ 'catalog.auto-update-product-variant-name' | translate }}</label>
-                    </clr-checkbox-wrapper>
-                </div>
-                <vdr-form-field
-                    [label]="'catalog.slug' | translate"
-                    for="slug"
-                    [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
-                >
-                    <input
-                        id="slug"
-                        type="text"
-                        formControlName="slug"
-                        [readonly]="!(updatePermissions | hasPermission)"
-                    />
-                </vdr-form-field>
-                <vdr-form-field
-                    [label]="'common.description' | translate"
-                    for="slug"
-                    [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
-                    class="card-span"
-                >
-                    <vdr-rich-text-editor
-                        formControlName="description"
-                        [readonly]="!(updatePermissions | hasPermission)"
-                    ></vdr-rich-text-editor>
-                </vdr-form-field>
-            </vdr-card>
-            <vdr-card [title]="'common.custom-fields' | translate" *ngIf="customFields.length">
-                <vdr-tabbed-custom-fields
-                    entityName="Product"
-                    [customFields]="customFields"
-                    [customFieldsFormGroup]="detailForm.get(['customFields'])"
-                    [readonly]="!(updatePermissions | hasPermission)"
-                ></vdr-tabbed-custom-fields>
-            </vdr-card>
-            <vdr-custom-detail-component-host
-                locationId="product-detail"
-                [entity$]="entity$"
-                [detailForm]="detailForm"
-            ></vdr-custom-detail-component-host>
-            <vdr-card [title]="'catalog.assets' | translate">
-                <vdr-assets
-                    class="card-span"
-                    [assets]="assetChanges.assets || product.assets"
-                    [featuredAsset]="assetChanges.featuredAsset || product.featuredAsset"
-                    [updatePermissions]="updatePermissions"
-                    (change)="assetChanges = $event"
-                ></vdr-assets>
-            </vdr-card>
-
-            <vdr-card [title]="'catalog.product-variants' | translate" [paddingX]="false">
-                <div class="card-span">
-                    <div *ngIf="isNew$ | async; else variantList">
-                        <vdr-generate-product-variants
-                            (variantsChange)="createVariantsConfig = $event"
-                        ></vdr-generate-product-variants>
-                    </div>
-                    <ng-template #variantList>
-                        <vdr-product-variant-list
-                            [productId]="this.id"
-                            [hideLanguageSelect]="true"
-                        ></vdr-product-variant-list>
-                    </ng-template>
-                </div>
-            </vdr-card>
-        </vdr-page-block>
-    </vdr-page-detail-layout>
-</form>

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

@@ -1,64 +0,0 @@
-@import 'variables';
-
-:host {
-    ::ng-deep {
-        trix-toolbar {
-            top: 24px;
-        }
-    }
-}
-
-.facets {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 3px;
-}
-
-vdr-action-bar clr-toggle-wrapper {
-    margin-top: 12px;
-}
-
-.variant-filter {
-    flex: 1;
-    display: flex;
-    input {
-        flex: 1;
-        max-width: initial;
-        border-radius: 3px 0 0 3px !important;
-    }
-    .icon-button {
-        border: 1px solid var(--color-component-border-300);
-        background-color: var(--color-component-bg-100);
-        border-radius: 0 3px 3px 0;
-        border-left: none;
-    }
-}
-
-.group-name {
-    padding-right: 6px;
-}
-
-.view-mode {
-    display: flex;
-    flex-direction: column;
-    justify-content: space-between;
-    @media screen and (min-width: $breakpoint-small) {
-        flex-direction: row;
-    }
-}
-
-.edit-variants-btn {
-    margin-top: 0;
-}
-
-.channel-assignment {
-    flex-wrap: wrap;
-    max-height: 144px;
-    overflow-y: auto;
-}
-
-.pagination-row {
-    display: flex;
-    align-items: baseline;
-    justify-content: space-between;
-}

+ 0 - 461
packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.ts

@@ -1,461 +0,0 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
-import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import {
-    Asset,
-    BaseDetailComponent,
-    CreateProductInput,
-    createUpdatedTranslatable,
-    DataService,
-    findTranslation,
-    getChannelCodeFromUserStatus,
-    GetProductWithVariantsQuery,
-    LanguageCode,
-    ModalService,
-    NotificationService,
-    Permission,
-    ProductDetailFragment,
-    ProductVariantFragment,
-    ServerConfigService,
-    unicodePatternValidator,
-    UpdateProductInput,
-    UpdateProductMutation,
-    UpdateProductOptionInput,
-    UpdateProductVariantInput,
-    UpdateProductVariantsMutation,
-} from '@vendure/admin-ui/core';
-import { normalizeString } from '@vendure/common/lib/normalize-string';
-import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
-import { unique } from '@vendure/common/lib/unique';
-import { combineLatest, concat, EMPTY, from, Observable } from 'rxjs';
-import {
-    distinctUntilChanged,
-    map,
-    mergeMap,
-    shareReplay,
-    skip,
-    switchMap,
-    switchMapTo,
-    take,
-} from 'rxjs/operators';
-
-import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
-import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
-import { AssignProductsToChannelDialogComponent } from '../assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
-import { CreateProductVariantsConfig } from '../generate-product-variants/generate-product-variants.component';
-
-interface SelectedAssets {
-    assets?: Asset[];
-    featuredAsset?: Asset;
-}
-
-@Component({
-    selector: 'vdr-product-detail2',
-    templateUrl: './product-detail.component.html',
-    styleUrls: ['./product-detail.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class ProductDetail2Component
-    extends BaseDetailComponent<NonNullable<GetProductWithVariantsQuery['product']>>
-    implements OnInit, OnDestroy
-{
-    product$: Observable<NonNullable<GetProductWithVariantsQuery['product']>>;
-    readonly customFields = this.getCustomFieldConfig('Product');
-    detailForm = this.formBuilder.group({
-        enabled: true,
-        name: ['', Validators.required],
-        autoUpdateVariantNames: true,
-        slug: ['', unicodePatternValidator(/^[\p{Letter}0-9_-]+$/)],
-        description: '',
-        facetValueIds: [[] as string[]],
-        customFields: this.formBuilder.group(
-            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-        ),
-    });
-    assetChanges: SelectedAssets = {};
-    productChannels$: Observable<ProductDetailFragment['channels']>;
-    facetValues$: Observable<ProductDetailFragment['facetValues']>;
-    createVariantsConfig: CreateProductVariantsConfig = { groups: [], variants: [] };
-    public readonly updatePermissions = [Permission.UpdateCatalog, Permission.UpdateProduct];
-
-    constructor(
-        route: ActivatedRoute,
-        router: Router,
-        serverConfigService: ServerConfigService,
-        private productDetailService: ProductDetailService,
-        private formBuilder: FormBuilder,
-        private modalService: ModalService,
-        private notificationService: NotificationService,
-        protected dataService: DataService,
-        private changeDetector: ChangeDetectorRef,
-    ) {
-        super(route, router, serverConfigService, dataService);
-    }
-
-    ngOnInit() {
-        this.init();
-        this.product$ = this.entity$;
-        const productFacetValues$ = this.product$.pipe(map(product => product.facetValues));
-        const productGroup = this.detailForm;
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        const formFacetValueIdChanges$ = productGroup.get('facetValueIds')!.valueChanges.pipe(
-            skip(1),
-            distinctUntilChanged(),
-            switchMap(ids =>
-                this.dataService.facet
-                    .getFacetValues({ filter: { id: { in: ids } } })
-                    .mapSingle(({ facetValues }) => facetValues.items),
-            ),
-            shareReplay(1),
-        );
-        this.facetValues$ = concat(
-            productFacetValues$.pipe(take(1)),
-            productFacetValues$.pipe(switchMapTo(formFacetValueIdChanges$)),
-        );
-        this.productChannels$ = this.product$.pipe(map(p => p.channels));
-    }
-
-    ngOnDestroy() {
-        this.destroy();
-    }
-
-    isDefaultChannel(channelCode: string): boolean {
-        return channelCode === DEFAULT_CHANNEL_CODE;
-    }
-
-    assignToChannel() {
-        this.productChannels$
-            .pipe(
-                take(1),
-                switchMap(channels =>
-                    this.modalService.fromComponent(AssignProductsToChannelDialogComponent, {
-                        size: 'lg',
-                        locals: {
-                            productIds: [this.id],
-                            currentChannelIds: channels.map(c => c.id),
-                        },
-                    }),
-                ),
-            )
-            .subscribe();
-    }
-
-    removeFromChannel(channelId: string) {
-        from(getChannelCodeFromUserStatus(this.dataService, channelId))
-            .pipe(
-                switchMap(({ channelCode }) =>
-                    this.modalService.dialog({
-                        title: _('catalog.remove-product-from-channel'),
-                        buttons: [
-                            { type: 'secondary', label: _('common.cancel') },
-                            {
-                                type: 'danger',
-                                label: _('catalog.remove-from-channel'),
-                                translationVars: { channelCode },
-                                returnValue: true,
-                            },
-                        ],
-                    }),
-                ),
-                switchMap(response =>
-                    response
-                        ? this.dataService.product.removeProductsFromChannel({
-                              channelId,
-                              productIds: [this.id],
-                          })
-                        : EMPTY,
-                ),
-            )
-            .subscribe(
-                () => {
-                    this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
-                },
-                err => {
-                    this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
-                },
-            );
-    }
-
-    assignVariantToChannel(variant: ProductVariantFragment) {
-        return this.modalService
-            .fromComponent(AssignProductsToChannelDialogComponent, {
-                size: 'lg',
-                locals: {
-                    productIds: [this.id],
-                    productVariantIds: [variant.id],
-                    currentChannelIds: variant.channels.map(c => c.id),
-                },
-            })
-            .subscribe();
-    }
-
-    removeVariantFromChannel({ channelId, variant }: { channelId: string; variant: ProductVariantFragment }) {
-        from(getChannelCodeFromUserStatus(this.dataService, channelId))
-            .pipe(
-                switchMap(({ channelCode }) =>
-                    this.modalService.dialog({
-                        title: _('catalog.remove-product-variant-from-channel'),
-                        buttons: [
-                            { type: 'secondary', label: _('common.cancel') },
-                            {
-                                type: 'danger',
-                                label: _('catalog.remove-from-channel'),
-                                translationVars: { channelCode },
-                                returnValue: true,
-                            },
-                        ],
-                    }),
-                ),
-                switchMap(response =>
-                    response
-                        ? this.dataService.product.removeVariantsFromChannel({
-                              channelId,
-                              productVariantIds: [variant.id],
-                          })
-                        : EMPTY,
-                ),
-            )
-            .subscribe(
-                () => {
-                    this.notificationService.success(_('catalog.notify-remove-variant-from-channel-success'));
-                },
-                err => {
-                    this.notificationService.error(_('catalog.notify-remove-variant-from-channel-error'));
-                },
-            );
-    }
-
-    assetsChanged(): boolean {
-        return !!Object.values(this.assetChanges).length;
-    }
-
-    /**
-     * If creating a new product, automatically generate the slug based on the product name.
-     */
-    updateSlug(nameValue: string) {
-        combineLatest(this.entity$, this.languageCode$)
-            .pipe(take(1))
-            .subscribe(([entity, languageCode]) => {
-                const slugControl = this.detailForm.get(['product', 'slug']);
-                const currentTranslation = findTranslation(entity, languageCode);
-                const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
-                if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
-                    slugControl.setValue(normalizeString(`${nameValue}`, '-'));
-                }
-            });
-    }
-
-    selectProductFacetValue() {
-        this.displayFacetValueModal().subscribe(facetValueIds => {
-            if (facetValueIds) {
-                const productGroup = this.detailForm;
-                const currentFacetValueIds = productGroup.value.facetValueIds ?? [];
-                productGroup.patchValue({
-                    facetValueIds: unique([...currentFacetValueIds, ...facetValueIds]),
-                });
-                productGroup.markAsDirty();
-            }
-        });
-    }
-
-    updateProductOption(input: UpdateProductOptionInput & { autoUpdate: boolean }) {
-        combineLatest(this.product$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([product, languageCode]) =>
-                    this.productDetailService.updateProductOption(input, product, languageCode),
-                ),
-            )
-            .subscribe(
-                () => {
-                    this.notificationService.success(_('common.notify-update-success'), {
-                        entity: 'ProductOption',
-                    });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-update-error'), {
-                        entity: 'ProductOption',
-                    });
-                },
-            );
-    }
-
-    removeProductFacetValue(facetValueId: string) {
-        const productGroup = this.detailForm;
-        const currentFacetValueIds = productGroup.value.facetValueIds ?? [];
-        productGroup.patchValue({
-            facetValueIds: currentFacetValueIds.filter(id => id !== facetValueId),
-        });
-        productGroup.markAsDirty();
-    }
-
-    private displayFacetValueModal(): Observable<string[] | undefined> {
-        return this.modalService
-            .fromComponent(ApplyFacetDialogComponent, {
-                size: 'md',
-                closable: true,
-            })
-            .pipe(map(facetValues => facetValues && facetValues.map(v => v.id)));
-    }
-
-    create() {
-        const productGroup = this.detailForm;
-        if (!productGroup.dirty) {
-            return;
-        }
-        combineLatest(this.product$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([product, languageCode]) => {
-                    const newProduct = this.getUpdatedProduct(
-                        product,
-                        productGroup as UntypedFormGroup,
-                        languageCode,
-                    ) as CreateProductInput;
-                    return this.productDetailService.createProductWithVariants(
-                        newProduct,
-                        this.createVariantsConfig,
-                        languageCode,
-                    );
-                }),
-            )
-            .subscribe(
-                ({ createProductVariants, productId }) => {
-                    this.notificationService.success(_('common.notify-create-success'), {
-                        entity: 'Product',
-                    });
-                    this.assetChanges = {};
-                    this.detailForm.markAsPristine();
-                    this.router.navigate(['../', productId], { relativeTo: this.route });
-                },
-                err => {
-                    // eslint-disable-next-line no-console
-                    console.error(err);
-                    this.notificationService.error(_('common.notify-create-error'), {
-                        entity: 'Product',
-                    });
-                },
-            );
-    }
-
-    save() {
-        combineLatest(this.product$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([product, languageCode]) => {
-                    const productGroup = this.detailForm;
-                    let productInput: UpdateProductInput | undefined;
-                    let variantsInput: UpdateProductVariantInput[] | undefined;
-
-                    if (productGroup.dirty || this.assetsChanged()) {
-                        productInput = this.getUpdatedProduct(
-                            product,
-                            productGroup as UntypedFormGroup,
-                            languageCode,
-                        ) as UpdateProductInput;
-                    }
-
-                    return this.productDetailService.updateProduct({
-                        product,
-                        languageCode,
-                        autoUpdate:
-                            this.detailForm.get(['product', 'autoUpdateVariantNames'])?.value ?? false,
-                        productInput,
-                        variantsInput,
-                    });
-                }),
-            )
-            .subscribe(
-                result => {
-                    this.updateSlugAfterSave(result);
-                    this.detailForm.markAsPristine();
-                    this.assetChanges = {};
-                    this.notificationService.success(_('common.notify-update-success'), {
-                        entity: 'Product',
-                    });
-                    this.changeDetector.markForCheck();
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-update-error'), {
-                        entity: 'Product',
-                    });
-                },
-            );
-    }
-
-    canDeactivate(): boolean {
-        return super.canDeactivate() && !this.assetChanges.assets && !this.assetChanges.featuredAsset;
-    }
-
-    /**
-     * Sets the values of the form on changes to the product or current language.
-     */
-    protected setFormValues(
-        product: NonNullable<GetProductWithVariantsQuery['product']>,
-        languageCode: LanguageCode,
-    ) {
-        const currentTranslation = findTranslation(product, languageCode);
-        this.detailForm.patchValue({
-            enabled: product.enabled,
-            name: currentTranslation ? currentTranslation.name : '',
-            slug: currentTranslation ? currentTranslation.slug : '',
-            description: currentTranslation ? currentTranslation.description : '',
-            facetValueIds: product.facetValues.map(fv => fv.id),
-        });
-
-        if (this.customFields.length) {
-            this.setCustomFieldFormValues(
-                this.customFields,
-                this.detailForm.get(['customFields']),
-                product,
-                currentTranslation,
-            );
-        }
-    }
-
-    /**
-     * Given a product and the value of the detailForm, this method creates an updated copy of the product which
-     * can then be persisted to the API.
-     */
-    private getUpdatedProduct(
-        product: NonNullable<GetProductWithVariantsQuery['product']>,
-        productFormGroup: UntypedFormGroup,
-        languageCode: LanguageCode,
-    ): UpdateProductInput | CreateProductInput {
-        const updatedProduct = createUpdatedTranslatable({
-            translatable: product,
-            updatedFields: productFormGroup.value,
-            customFieldConfig: this.customFields,
-            languageCode,
-            defaultTranslation: {
-                languageCode,
-                name: product.name || '',
-                slug: product.slug || '',
-                description: product.description || '',
-            },
-        });
-        return {
-            ...updatedProduct,
-            assetIds: this.assetChanges.assets?.map(a => a.id),
-            featuredAssetId: this.assetChanges.featuredAsset?.id,
-            facetValueIds: productFormGroup.value.facetValueIds,
-        } as UpdateProductInput | CreateProductInput;
-    }
-
-    /**
-     * The server may alter the slug value in order to normalize and ensure uniqueness upon saving.
-     */
-    private updateSlugAfterSave(results: Array<UpdateProductMutation | UpdateProductVariantsMutation>) {
-        const firstResult = results[0];
-        const slugControl = this.detailForm.get(['product', 'slug']);
-
-        function isUpdateMutation(input: any): input is UpdateProductMutation {
-            return input.hasOwnProperty('updateProduct');
-        }
-
-        if (slugControl && isUpdateMutation(firstResult)) {
-            slugControl.setValue(firstResult.updateProduct.slug, { emitEvent: false });
-        }
-    }
-}

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

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormBuilder, FormControl, FormGroup, UntypedFormGroup } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
@@ -70,7 +70,7 @@ type T1 = T['stockLevels'];
 })
 export class ProductVariantDetailComponent
     extends TypedBaseDetailComponent<typeof ProductVariantDetailQueryDocument, 'productVariant'>
-    implements OnInit
+    implements OnInit, OnDestroy
 {
     public readonly updatePermissions = [Permission.UpdateCatalog, Permission.UpdateProduct];
     readonly customFields = this.getCustomFieldConfig('ProductVariant');
@@ -155,6 +155,10 @@ export class ProductVariantDetailComponent
         );
     }
 
+    ngOnDestroy() {
+        this.destroy();
+    }
+
     save() {
         combineLatest(this.entity$, this.languageCode$)
             .pipe(
@@ -231,7 +235,7 @@ export class ProductVariantDetailComponent
     }
 
     optionGroupName(optionGroupId: string): string | undefined {
-        const group = this.entity.product.optionGroups.find(g => g.id === optionGroupId);
+        const group = this.entity?.product.optionGroups.find(g => g.id === optionGroupId);
         if (group) {
             const translation =
                 group?.translations.find(t => t.languageCode === this.languageCode) ?? group.translations[0];

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

@@ -1,296 +0,0 @@
-<div class="variants-list">
-    <div
-        class="variant-container card"
-        *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">
-            <div class="card-block header-row">
-                <div class="details">
-                    <vdr-title-input class="sku" [readonly]="!(updatePermission | hasPermission)">
-                        <clr-input-container>
-                            <input
-                                clrInput
-                                type="text"
-                                formControlName="sku"
-                                [readonly]="!(updatePermission | hasPermission)"
-                                [placeholder]="'catalog.sku' | translate"
-                            />
-                        </clr-input-container>
-                    </vdr-title-input>
-                    <vdr-title-input class="name" [readonly]="!(updatePermission | hasPermission)">
-                        <clr-input-container>
-                            <input
-                                clrInput
-                                type="text"
-                                formControlName="name"
-                                [readonly]="!(updatePermission | hasPermission)"
-                                [placeholder]="'common.name' | translate"
-                            />
-                        </clr-input-container>
-                    </vdr-title-input>
-                </div>
-                <div class="right-controls">
-                    <clr-toggle-wrapper *vdrIfPermissions="updatePermission">
-                        <input type="checkbox" clrToggle name="enabled" formControlName="enabled" />
-                        <label>{{ 'common.enabled' | translate }}</label>
-                    </clr-toggle-wrapper>
-                </div>
-            </div>
-            <div class="card-block">
-                <div class="variant-body">
-                    <div class="assets">
-                        <vdr-assets
-                            [compact]="true"
-                            [assets]="pendingAssetChanges[variant.id]?.assets || variant.assets"
-                            [featuredAsset]="
-                                pendingAssetChanges[variant.id]?.featuredAsset || variant.featuredAsset
-                            "
-                            [updatePermissions]="updatePermission"
-                            (change)="onAssetChange(variant.id, $event)"
-                        ></vdr-assets>
-                    </div>
-                    <div class="variant-form-inputs">
-                        <div class="standard-fields">
-                            <div class="variant-form-input-row">
-                                <div class="tax-category">
-                                    <clr-select-container
-                                        *vdrIfPermissions="updatePermission; else taxCategoryLabel"
-                                    >
-                                        <label>{{ 'catalog.tax-category' | translate }}</label>
-                                        <select clrSelect name="options" formControlName="taxCategoryId">
-                                            <option
-                                                *ngFor="let taxCategory of taxCategories"
-                                                [value]="taxCategory.id"
-                                            >
-                                                {{ taxCategory.name }}
-                                            </option>
-                                        </select>
-                                    </clr-select-container>
-                                    <ng-template #taxCategoryLabel>
-                                        <label class="clr-control-label">{{
-                                            'catalog.tax-category' | translate
-                                        }}</label>
-                                        <div class="tax-category-label">
-                                            {{ getTaxCategoryName(formGroup) }}
-                                        </div>
-                                    </ng-template>
-                                </div>
-                                <div class="price">
-                                    <clr-input-container>
-                                        <label>{{ 'catalog.price' | translate }}</label>
-                                        <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>
-                                </div>
-                                <vdr-variant-price-detail
-                                    [price]="formGroup.get('price')!.value"
-                                    [currencyCode]="variant.currencyCode"
-                                    [priceIncludesTax]="channelPriceIncludesTax"
-                                    [taxCategoryId]="formGroup.get('taxCategoryId')!.value"
-                                ></vdr-variant-price-detail>
-                            </div>
-                            <div class="variant-form-input-row">
-                                <clr-select-container *vdrIfPermissions="updatePermission">
-                                    <label
-                                        >{{ 'catalog.track-inventory' | translate }}
-                                        <vdr-help-tooltip
-                                            [content]="'catalog.track-inventory-tooltip' | translate"
-                                        ></vdr-help-tooltip>
-                                    </label>
-                                    <select clrSelect name="options" formControlName="trackInventory">
-                                        <option [value]="GlobalFlag.TRUE">
-                                            {{ 'catalog.track-inventory-true' | translate }}
-                                        </option>
-                                        <option [value]="GlobalFlag.FALSE">
-                                            {{ 'catalog.track-inventory-false' | translate }}
-                                        </option>
-                                        <option [value]="GlobalFlag.INHERIT">
-                                            {{ 'catalog.track-inventory-inherit' | translate }}
-                                        </option>
-                                    </select>
-                                </clr-select-container>
-                                <clr-input-container>
-                                    <label
-                                        >{{ 'catalog.stock-on-hand' | translate }}
-                                        <vdr-help-tooltip
-                                            [content]="'catalog.stock-on-hand-tooltip' | translate"
-                                        ></vdr-help-tooltip
-                                    ></label>
-                                    <input
-                                        [class.inventory-untracked]="inventoryIsNotTracked(formGroup)"
-                                        clrInput
-                                        type="number"
-                                        [min]="getStockOnHandMinValue(formGroup)"
-                                        step="1"
-                                        formControlName="stockOnHand"
-                                        [readonly]="!(updatePermission | hasPermission)"
-                                        [vdrDisabled]="inventoryIsNotTracked(formGroup)"
-                                    />
-                                </clr-input-container>
-                                <div [class.inventory-untracked]="inventoryIsNotTracked(formGroup)">
-                                    <label class="clr-control-label"
-                                        >{{ 'catalog.stock-allocated' | translate }}
-                                        <vdr-help-tooltip
-                                            [content]="'catalog.stock-allocated-tooltip' | translate"
-                                        ></vdr-help-tooltip
-                                    ></label>
-                                    <div class="value">
-                                        {{ variant.stockAllocated }}
-                                    </div>
-                                </div>
-                                <div [class.inventory-untracked]="inventoryIsNotTracked(formGroup)">
-                                    <label class="clr-control-label"
-                                        >{{ 'catalog.stock-saleable' | translate }}
-                                        <vdr-help-tooltip
-                                            [content]="'catalog.stock-saleable-tooltip' | translate"
-                                        ></vdr-help-tooltip
-                                    ></label>
-                                    <div class="value">
-                                        {{ getSaleableStockLevel(variant) }}
-                                    </div>
-                                </div>
-                            </div>
-
-                            <div class="variant-form-input-row">
-                                <div
-                                    class="out-of-stock-threshold-wrapper"
-                                    [class.inventory-untracked]="inventoryIsNotTracked(formGroup)"
-                                >
-                                    <label class="clr-control-label"
-                                        >{{ 'catalog.out-of-stock-threshold' | translate
-                                        }}<vdr-help-tooltip
-                                            [content]="'catalog.out-of-stock-threshold-tooltip' | translate"
-                                        ></vdr-help-tooltip
-                                    ></label>
-                                    <div class="flex">
-                                        <clr-input-container>
-                                            <input
-                                                clrInput
-                                                type="number"
-                                                [formControl]="formGroup.get('outOfStockThreshold')"
-                                                [readonly]="!(updatePermission | hasPermission)"
-                                                [vdrDisabled]="
-                                                    formGroup.get('useGlobalOutOfStockThreshold')?.value !==
-                                                        false || inventoryIsNotTracked(formGroup)
-                                                "
-                                            />
-                                        </clr-input-container>
-                                        <clr-toggle-wrapper>
-                                            <input
-                                                type="checkbox"
-                                                clrToggle
-                                                name="useGlobalOutOfStockThreshold"
-                                                formControlName="useGlobalOutOfStockThreshold"
-                                                [vdrDisabled]="
-                                                    !(updatePermission | hasPermission) ||
-                                                    inventoryIsNotTracked(formGroup)
-                                                "
-                                            />
-                                            <label
-                                                >{{ 'catalog.use-global-value' | translate }} ({{
-                                                    globalOutOfStockThreshold
-                                                }})</label
-                                            >
-                                        </clr-toggle-wrapper>
-                                    </div>
-                                </div>
-                            </div>
-                        </div>
-                        <div class="custom-fields">
-                            <div class="variant-form-input-row">
-                                <section formGroupName="customFields" *ngIf="customFields.length">
-                                    <vdr-tabbed-custom-fields
-                                        entityName="ProductVariant"
-                                        [customFields]="customFields"
-                                        [compact]="true"
-                                        [customFieldsFormGroup]="formGroup.get('customFields')"
-                                        [readonly]="!(updatePermission | hasPermission)"
-                                    ></vdr-tabbed-custom-fields>
-                                </section>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div class="card-block">
-                <div class="options-facets">
-                    <vdr-entity-info [entity]="variant"></vdr-entity-info>
-                    <div *ngIf="variant.options.length">
-                        <div class="options">
-                            <vdr-chip
-                                *ngFor="let option of variant.options | sort: 'groupId'"
-                                [colorFrom]="optionGroupName(option.groupId)"
-                                [invert]="true"
-                                (iconClick)="editOption(option)"
-                                [icon]="(updatePermission | hasPermission) && 'pencil'"
-                            >
-                                <span class="option-group-name">{{ optionGroupName(option.groupId) }}</span>
-                                {{ optionName(option) }}
-                            </vdr-chip>
-                            <a [routerLink]="['./', 'options']" class="btn btn-link btn-sm"
-                                >{{ 'catalog.edit-options' | translate }}...</a
-                            >
-                        </div>
-                    </div>
-                    <div class="flex-spacer"></div>
-                    <div class="facets">
-                        <vdr-facet-value-chip
-                            *ngFor="let facetValue of currentOrPendingFacetValues(variant)"
-                            [facetValue]="facetValue"
-                            [removable]="updatePermission | hasPermission"
-                            (remove)="removeFacetValue(variant, facetValue.id)"
-                        ></vdr-facet-value-chip>
-                        <button
-                            *vdrIfPermissions="updatePermission"
-                            class="btn btn-sm btn-secondary"
-                            (click)="selectFacetValue(variant)"
-                        >
-                            <clr-icon shape="plus"></clr-icon>
-                            {{ 'catalog.add-facets' | translate }}
-                        </button>
-                    </div>
-                </div>
-            </div>
-            <ng-container *vdrIfMultichannel>
-                <div class="card-block" *vdrIfDefaultChannelActive>
-                    <div class="flex channel-assignment">
-                        <ng-container *ngFor="let channel of variant.channels">
-                            <vdr-chip
-                                *ngIf="!isDefaultChannel(channel.code)"
-                                icon="times-circle"
-                                [title]="'catalog.remove-from-channel' | translate: { channelCode: channel.code }"
-                                (iconClick)="
-                                    removeFromChannel.emit({ channelId: channel.id, variant: variant })
-                                "
-                            >
-                                <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
-                                {{ channel.code | channelCodeToLabel }}
-                            </vdr-chip>
-                        </ng-container>
-                        <button class="btn btn-sm" (click)="assignToChannel.emit(variant)">
-                            <clr-icon shape="layers"></clr-icon>
-                            {{ 'catalog.assign-to-channel' | translate }}
-                        </button>
-                    </div>
-                </div>
-            </ng-container>
-        </ng-container>
-    </div>
-</div>

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

@@ -1,168 +0,0 @@
-@import "variables";
-
-.with-selected {
-    display: flex;
-    min-height: 52px;
-    align-items: center;
-    border: 1px solid var(--color-component-border-100);
-    border-radius: 3px;
-    padding: 6px 18px;
-
-    vdr-select-toggle {
-        margin-right: 12px;
-    }
-
-    > label {
-        margin-right: 12px;
-    }
-}
-
-.variant-container {
-    transition: background-color 0.2s;
-    min-height: 330px;
-
-    &.disabled {
-        background-color: var(--color-component-bg-200);
-    }
-
-    .header-row {
-        display: flex;
-        align-items: center;
-        flex-wrap: wrap;
-    }
-
-    .variant-body {
-        display: flex;
-        flex-direction: column;
-        @media screen and (min-width: $breakpoint-small) {
-            flex-direction: row;
-        }
-    }
-
-    .details {
-        display: flex;
-        flex-direction: column;
-        flex: 1;
-        margin-right: 12px;
-        @media screen and (min-width: $breakpoint-small) {
-            flex-direction: row;
-            height: 36px;
-        }
-
-        .name {
-            flex: 1;
-
-            ::ng-deep .clr-control-container {
-                width: 100%;
-
-                input.clr-input {
-                    min-width: 100%;
-                }
-            }
-        }
-
-        .sku {
-            width: 160px;
-            margin-right: 20px;
-            flex: 0;
-        }
-
-        ::ng-deep .name input {
-            min-width: 300px;
-        }
-    }
-
-    .right-controls {
-        display: flex;
-    }
-
-    .tax-category-label {
-        margin-top: 3px;
-    }
-
-    .variant-form-inputs {
-        flex: 1;
-        display: flex;
-        flex-direction: column;
-        @media screen and (min-width: $breakpoint-small) {
-            flex-direction: row;
-        }
-    }
-
-    .variant-form-input-row {
-        display: flex;
-        flex-wrap: wrap;
-        @media screen and (min-width: $breakpoint-small) {
-            margin: 0 6px 8px 24px;
-        }
-
-        > * {
-            margin-right: 24px;
-            margin-bottom: 24px;
-        }
-    }
-
-    .track-inventory-toggle {
-        margin-top: 22px;
-    }
-
-    .clr-form-control {
-        margin-top: 0;
-    }
-
-    .facets {
-        display: flex;
-        flex-wrap: wrap;
-        align-items: center;
-    }
-
-    .pricing {
-        display: flex;
-
-        > div {
-            margin-right: 12px;
-        }
-    }
-
-    .option-group-name {
-        color: var(--color-text-200);
-        text-transform: uppercase;
-        font-size: 10px;
-        margin-right: 3px;
-        height: 11px;
-    }
-
-    .options-facets {
-        display: flex;
-        color: var(--color-grey-400);
-    }
-
-    ::ng-deep .clr-control-container {
-        width: 100%;
-    }
-}
-
-.channel-assignment {
-    justify-content: flex-end;
-    flex-wrap: wrap;
-    max-height: 110px;
-    overflow-y: auto;
-
-    .btn {
-        margin: 6px 12px 6px 0;
-    }
-}
-
-.out-of-stock-threshold-wrapper {
-    display: flex;
-    flex-direction: column;
-
-    clr-toggle-wrapper {
-        margin-left: 24px;
-    }
-}
-
-.inventory-untracked {
-    opacity: 0.5;
-}
-

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

@@ -1,280 +0,0 @@
-import {
-    ChangeDetectionStrategy,
-    ChangeDetectorRef,
-    Component,
-    EventEmitter,
-    Input,
-    OnDestroy,
-    OnInit,
-    Output,
-} from '@angular/core';
-import { UntypedFormArray, UntypedFormGroup } from '@angular/forms';
-import {
-    CustomFieldConfig,
-    DataService,
-    FacetValueFragment,
-    FacetWithValuesFragment,
-    GlobalFlag,
-    LanguageCode,
-    ModalService,
-    Permission,
-    ProductDetailFragment,
-    ProductOptionFragment,
-    ProductVariantFragment,
-    TaxCategory,
-    UpdateProductOptionInput,
-} from '@vendure/admin-ui/core';
-import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
-import { unique } from '@vendure/common/lib/unique';
-import { Subscription } from 'rxjs';
-import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
-
-import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
-import { AssetChange } from '../assets/assets.component';
-import { PaginationConfig, SelectedAssets, VariantFormValue } from '../product-detail/product-detail.types';
-import { UpdateProductOptionDialogComponent } from '../update-product-option-dialog/update-product-option-dialog.component';
-
-export interface VariantAssetChange extends AssetChange {
-    variantId: string;
-}
-
-@Component({
-    selector: 'vdr-product-variants-list',
-    templateUrl: './product-variants-list.component.html',
-    styleUrls: ['./product-variants-list.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class ProductVariantsListComponent implements OnInit, OnDestroy {
-    @Input('productVariantsFormArray') formArray: UntypedFormArray;
-    @Input() variants: ProductVariantFragment[];
-    @Input() paginationConfig: PaginationConfig;
-    @Input() channelPriceIncludesTax: boolean;
-    @Input() taxCategories: TaxCategory[];
-    @Input() optionGroups: ProductDetailFragment['optionGroups'];
-    @Input() customFields: CustomFieldConfig[];
-    @Input() customOptionFields: CustomFieldConfig[];
-    @Input() activeLanguage: LanguageCode;
-    @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
-    @Input() pendingFacetValueChanges: { [variantId: string]: ProductVariantFragment['facetValues'] };
-    @Output() assignToChannel = new EventEmitter<ProductVariantFragment>();
-    @Output() removeFromChannel = new EventEmitter<{
-        channelId: string;
-        variant: ProductVariantFragment;
-    }>();
-    @Output() assetChange = new EventEmitter<VariantAssetChange>();
-    @Output() selectionChange = new EventEmitter<string[]>();
-    @Output() updateProductOption = new EventEmitter<UpdateProductOptionInput & { autoUpdate: boolean }>();
-    selectedVariantIds: string[] = [];
-    formGroupMap = new Map<string, UntypedFormGroup>();
-    GlobalFlag = GlobalFlag;
-    globalTrackInventory: boolean;
-    globalOutOfStockThreshold: number;
-    readonly updatePermission = [Permission.UpdateCatalog, Permission.UpdateProduct];
-    private subscription: Subscription;
-
-    constructor(
-        private changeDetector: ChangeDetectorRef,
-        private modalService: ModalService,
-        private dataService: DataService,
-    ) {}
-
-    ngOnInit() {
-        this.dataService.settings.getGlobalSettings('cache-first').single$.subscribe(({ globalSettings }) => {
-            this.globalTrackInventory = globalSettings.trackInventory;
-            this.globalOutOfStockThreshold = globalSettings.outOfStockThreshold;
-            this.changeDetector.markForCheck();
-        });
-        this.subscription = this.formArray.valueChanges.subscribe(() => this.changeDetector.markForCheck());
-
-        this.subscription.add(
-            this.formArray.valueChanges
-                .pipe(
-                    map(value => value.length),
-                    debounceTime(1),
-                    distinctUntilChanged(),
-                )
-                .subscribe(() => {
-                    this.buildFormGroupMap();
-                }),
-        );
-
-        this.buildFormGroupMap();
-    }
-
-    ngOnDestroy() {
-        if (this.subscription) {
-            this.subscription.unsubscribe();
-        }
-    }
-
-    isDefaultChannel(channelCode: string): boolean {
-        return channelCode === DEFAULT_CHANNEL_CODE;
-    }
-
-    trackById(index: number, item: ProductVariantFragment) {
-        return item.id;
-    }
-
-    inventoryIsNotTracked(formGroup: UntypedFormGroup): boolean {
-        const trackInventory = formGroup.get('trackInventory')?.value;
-        return (
-            trackInventory === GlobalFlag.FALSE ||
-            (trackInventory === GlobalFlag.INHERIT && this.globalTrackInventory === false)
-        );
-    }
-
-    getTaxCategoryName(group: UntypedFormGroup): string {
-        const control = group.get(['taxCategoryId']);
-        if (control && this.taxCategories) {
-            const match = this.taxCategories.find(t => t.id === control.value);
-            return match ? match.name : '';
-        }
-        return '';
-    }
-
-    getStockOnHandMinValue(variant: UntypedFormGroup) {
-        const effectiveOutOfStockThreshold = variant.get('useGlobalOutOfStockThreshold')?.value
-            ? this.globalOutOfStockThreshold
-            : variant.get('outOfStockThreshold')?.value;
-        return effectiveOutOfStockThreshold;
-    }
-
-    getSaleableStockLevel(variant: ProductVariantFragment) {
-        const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold
-            ? this.globalOutOfStockThreshold
-            : variant.outOfStockThreshold;
-        return variant.stockOnHand - variant.stockAllocated - effectiveOutOfStockThreshold;
-    }
-
-    areAllSelected(): boolean {
-        return !!this.variants && this.selectedVariantIds.length === this.variants.length;
-    }
-
-    onAssetChange(variantId: string, event: AssetChange) {
-        this.assetChange.emit({
-            variantId,
-            ...event,
-        });
-        const index = this.formArray.controls.findIndex(c => c.value.id === variantId);
-        this.formArray.at(index).markAsDirty();
-    }
-
-    toggleSelectAll() {
-        if (this.areAllSelected()) {
-            this.selectedVariantIds = [];
-        } else {
-            this.selectedVariantIds = this.variants.map(v => v.id);
-        }
-        this.selectionChange.emit(this.selectedVariantIds);
-    }
-
-    toggleSelectVariant(variantId: string) {
-        const index = this.selectedVariantIds.indexOf(variantId);
-        if (-1 < index) {
-            this.selectedVariantIds.splice(index, 1);
-        } else {
-            this.selectedVariantIds.push(variantId);
-        }
-        this.selectionChange.emit(this.selectedVariantIds);
-    }
-
-    optionGroupName(optionGroupId: string): string | undefined {
-        const group = this.optionGroups.find(g => g.id === optionGroupId);
-        if (group) {
-            const translation =
-                group?.translations.find(t => t.languageCode === this.activeLanguage) ??
-                group.translations[0];
-            return translation.name;
-        }
-    }
-
-    optionName(option: ProductOptionFragment) {
-        const translation =
-            option.translations.find(t => t.languageCode === this.activeLanguage) ?? option.translations[0];
-        return translation.name;
-    }
-
-    currentOrPendingFacetValues(variant: ProductVariantFragment) {
-        return this.pendingFacetValueChanges[variant.id] ?? variant.facetValues;
-    }
-
-    selectFacetValue(variant: ProductVariantFragment) {
-        return this.modalService
-            .fromComponent(ApplyFacetDialogComponent, {
-                size: 'md',
-                closable: true,
-            })
-            .subscribe(facetValues => {
-                if (facetValues) {
-                    const existingFacetValueIds = variant ? variant.facetValues.map(fv => fv.id) : [];
-                    const variantFormGroup = this.formArray.controls.find(c => c.value.id === variant.id);
-                    if (variantFormGroup) {
-                        const uniqueFacetValueIds = unique([
-                            ...existingFacetValueIds,
-                            ...facetValues.map(fv => fv.id),
-                        ]);
-                        variantFormGroup.patchValue({ facetValueIds: uniqueFacetValueIds });
-                        variantFormGroup.markAsDirty();
-                        if (!this.pendingFacetValueChanges[variant.id]) {
-                            this.pendingFacetValueChanges[variant.id] = variant.facetValues.slice(0);
-                        }
-                        this.pendingFacetValueChanges[variant.id].push(...facetValues);
-                    }
-                    this.changeDetector.markForCheck();
-                }
-            });
-    }
-
-    removeFacetValue(variant: ProductVariantFragment, facetValueId: string) {
-        const formGroup = this.formGroupMap.get(variant.id);
-        if (formGroup) {
-            const newValue = (formGroup.value as VariantFormValue).facetValueIds.filter(
-                id => id !== facetValueId,
-            );
-            formGroup.patchValue({
-                facetValueIds: newValue,
-            });
-            formGroup.markAsDirty();
-            if (!this.pendingFacetValueChanges[variant.id]) {
-                this.pendingFacetValueChanges[variant.id] = variant.facetValues.slice(0);
-            }
-            this.pendingFacetValueChanges[variant.id] = this.pendingFacetValueChanges[variant.id].filter(
-                fv => fv.id !== facetValueId,
-            );
-        }
-    }
-
-    isVariantSelected(variantId: string): boolean {
-        return -1 < this.selectedVariantIds.indexOf(variantId);
-    }
-
-    editOption(option: ProductVariantFragment['options'][number]) {
-        this.modalService
-            .fromComponent(UpdateProductOptionDialogComponent, {
-                size: 'md',
-                locals: {
-                    productOption: option,
-                    activeLanguage: this.activeLanguage,
-                    customFields: this.customOptionFields,
-                },
-            })
-            .subscribe(result => {
-                if (result) {
-                    this.updateProductOption.emit(result);
-                }
-            });
-    }
-
-    private buildFormGroupMap() {
-        this.formGroupMap.clear();
-        for (const controlGroup of this.formArray.controls) {
-            this.formGroupMap.set(controlGroup.value.id, controlGroup as UntypedFormGroup);
-        }
-        this.changeDetector.markForCheck();
-    }
-
-    private getFacetValueIds(id: string): string[] {
-        const formValue: VariantFormValue = this.formGroupMap.get(id)?.value;
-        return formValue.facetValueIds;
-    }
-}

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

@@ -7,11 +7,20 @@ import {
     OnInit,
 } from '@angular/core';
 import { UntypedFormArray, UntypedFormGroup } from '@angular/forms';
-import { Permission, ProductDetailFragment, ProductVariantFragment } from '@vendure/admin-ui/core';
+import { Asset, Permission, ProductDetailFragment, ProductVariantFragment } from '@vendure/admin-ui/core';
 import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
-import { PaginationConfig, SelectedAssets } from '../product-detail/product-detail.types';
+interface SelectedAssets {
+    assets?: Asset[];
+    featuredAsset?: Asset;
+}
+
+interface PaginationConfig {
+    totalItems: number;
+    currentPage: number;
+    itemsPerPage: number;
+}
 
 @Component({
     selector: 'vdr-product-variants-table',

+ 3 - 2
packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts

@@ -6,6 +6,7 @@ import {
     DeletionResult,
     FacetWithValuesFragment,
     findTranslation,
+    GetProductDetailQuery,
     GetProductWithVariantsQuery,
     LanguageCode,
     UpdateProductInput,
@@ -143,7 +144,7 @@ export class ProductDetailService {
     }
 
     updateProduct(updateOptions: {
-        product: NonNullable<GetProductWithVariantsQuery['product']>;
+        product: NonNullable<GetProductDetailQuery['product']>;
         languageCode: LanguageCode;
         autoUpdate: boolean;
         productInput?: UpdateProductInput;
@@ -212,7 +213,7 @@ export class ProductDetailService {
 
     updateProductOption(
         input: UpdateProductOptionInput & { autoUpdate: boolean },
-        product: NonNullable<GetProductWithVariantsQuery['product']>,
+        product: NonNullable<GetProductDetailQuery['product']>,
         languageCode: LanguageCode,
     ) {
         const variants$ = input.autoUpdate

+ 0 - 30
packages/admin-ui/src/lib/catalog/src/providers/routing/asset-resolver.ts

@@ -1,30 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import { AssetFragment, AssetType, BaseEntityResolver, DataService } from '@vendure/admin-ui/core';
-
-@Injectable({
-    providedIn: 'root',
-})
-export class AssetResolver extends BaseEntityResolver<AssetFragment> {
-    constructor(router: Router, dataService: DataService) {
-        super(
-            router,
-            {
-                __typename: 'Asset' as const,
-                id: '',
-                createdAt: '',
-                updatedAt: '',
-                name: '',
-                type: AssetType.IMAGE,
-                fileSize: 0,
-                mimeType: '',
-                width: 0,
-                height: 0,
-                source: '',
-                preview: '',
-                focalPoint: null,
-            },
-            id => dataService.product.getAsset(id).mapStream(data => data.asset),
-        );
-    }
-}

+ 0 - 39
packages/admin-ui/src/lib/catalog/src/providers/routing/collection-resolver.ts

@@ -1,39 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import {
-    BaseEntityResolver,
-    CollectionFragment,
-    DataService,
-    getDefaultUiLanguage,
-} from '@vendure/admin-ui/core';
-
-@Injectable({
-    providedIn: 'root',
-})
-export class CollectionResolver extends BaseEntityResolver<CollectionFragment> {
-    constructor(router: Router, dataService: DataService) {
-        super(
-            router,
-            {
-                __typename: 'Collection' as 'Collection',
-                id: '',
-                createdAt: '',
-                updatedAt: '',
-                languageCode: getDefaultUiLanguage(),
-                name: '',
-                slug: '',
-                isPrivate: false,
-                breadcrumbs: [],
-                description: '',
-                featuredAsset: null,
-                assets: [],
-                translations: [],
-                inheritFilters: true,
-                filters: [],
-                parent: {} as any,
-                children: null,
-            },
-            id => dataService.collection.getCollection(id).mapStream(data => data.collection),
-        );
-    }
-}

+ 0 - 32
packages/admin-ui/src/lib/catalog/src/providers/routing/facet-resolver.ts

@@ -1,32 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import {
-    BaseEntityResolver,
-    DataService,
-    FacetWithValuesFragment,
-    getDefaultUiLanguage,
-} from '@vendure/admin-ui/core';
-
-@Injectable({
-    providedIn: 'root',
-})
-export class FacetResolver extends BaseEntityResolver<FacetWithValuesFragment> {
-    constructor(router: Router, dataService: DataService) {
-        super(
-            router,
-            {
-                __typename: 'Facet' as 'Facet',
-                id: '',
-                createdAt: '',
-                updatedAt: '',
-                isPrivate: false,
-                languageCode: getDefaultUiLanguage(),
-                name: '',
-                code: '',
-                translations: [],
-                values: [],
-            },
-            id => dataService.facet.getFacet(id).mapStream(data => data.facet),
-        );
-    }
-}

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

@@ -28,8 +28,6 @@ export * from './components/generate-product-variants/generate-product-variants.
 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-detail2/product-detail.component';
 export * from './components/product-list/product-list-bulk-actions';
 export * from './components/product-list/product-list.component';
 export * from './components/product-list/product-list.graphql';
@@ -40,14 +38,10 @@ export * from './components/product-variant-list/product-list-bulk-actions';
 export * from './components/product-variant-list/product-variant-list.component';
 export * from './components/product-variant-list/product-variant-list.graphql';
 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';
 export * from './components/update-product-option-dialog/update-product-option-dialog.component';
 export * from './components/variant-price-detail/variant-price-detail.component';
 export * from './providers/product-detail/product-detail.service';
 export * from './providers/product-detail/replace-last';
-export * from './providers/routing/asset-resolver';
-export * from './providers/routing/collection-resolver';
-export * from './providers/routing/facet-resolver';
 export * from './providers/routing/product-resolver';
 export * from './providers/routing/product-variants-resolver';

+ 29 - 9
packages/admin-ui/src/lib/core/src/common/base-detail.component.ts

@@ -75,17 +75,18 @@ export abstract class BaseDetailComponent<Entity extends { id: string; updatedAt
     init() {
         this.entity$ = this.route.data.pipe(
             switchMap(data => (data.entity as Observable<Entity>).pipe(takeUntil(this.destroy$))),
+            filter(notNullOrUndefined),
             tap(entity => (this.id = entity.id)),
             shareReplay(1),
         );
+        this.isNew$ = this.entity$.pipe(
+            map(entity => !entity?.id),
+            shareReplay(1),
+        );
         this.setUpStreams();
     }
 
     protected setUpStreams() {
-        this.isNew$ = this.entity$.pipe(
-            map(entity => entity.id === ''),
-            shareReplay(1),
-        );
         this.languageCode$ = this.route.paramMap.pipe(
             map(paramMap => paramMap.get('lang')),
             switchMap(lang => {
@@ -105,7 +106,9 @@ export abstract class BaseDetailComponent<Entity extends { id: string; updatedAt
         combineLatest(this.entity$, this.languageCode$)
             .pipe(takeUntil(this.destroy$))
             .subscribe(([entity, languageCode]) => {
-                this.setFormValues(entity, languageCode);
+                if (entity) {
+                    this.setFormValues(entity, languageCode);
+                }
                 this.detailForm.markAsPristine();
             });
     }
@@ -171,12 +174,23 @@ export abstract class TypedBaseDetailComponent<
     Field extends keyof ResultOf<T>,
 > extends BaseDetailComponent<NonNullable<ResultOf<T>[Field]>> {
     protected result$: Observable<ResultOf<T>>;
-    protected entity: NonNullable<ResultOf<T>[Field]>;
+    protected entity: ResultOf<T>[Field];
+
+    protected constructor(
+        route?: ActivatedRoute,
+        router?: Router,
+        serverConfigService?: ServerConfigService,
+        dataService?: DataService,
+    ) {
+        super(inject(ActivatedRoute), inject(Router), inject(ServerConfigService), inject(DataService));
+    }
+
     override init() {
         this.entity$ = this.route.data.pipe(
             switchMap(data =>
                 (data.detail.entity as Observable<ResultOf<T>[Field]>).pipe(takeUntil(this.destroy$)),
             ),
+            filter(notNullOrUndefined),
             tap(entity => {
                 this.id = entity.id;
                 this.entity = entity;
@@ -187,6 +201,11 @@ export abstract class TypedBaseDetailComponent<
             map(data => data.detail.result),
             shareReplay(1),
         );
+        this.isNew$ = this.route.data.pipe(
+            switchMap(data => data.detail.entity),
+            map(entity => !entity),
+            shareReplay(1),
+        );
         this.setUpStreams();
     }
 }
@@ -194,11 +213,12 @@ export abstract class TypedBaseDetailComponent<
 export function detailComponentWithResolver<
     T extends TypedDocumentNode<any, { id: string }>,
     Field extends keyof ResultOf<T>,
+    R extends Field,
 >(config: {
     component: Type<TypedBaseDetailComponent<T, Field>>;
     query: T;
-    getEntity: (result: ResultOf<T>) => ResultOf<T>[Field];
-    getBreadcrumbs?: (entity: ResultOf<T>) => BreadcrumbValue;
+    entityKey: R;
+    getBreadcrumbs?: (entity: ResultOf<T>[R]) => BreadcrumbValue;
 }) {
     const resolveFn: ResolveFn<{ entity: Observable<ResultOf<T>[Field] | null>; result?: ResultOf<T> }> = (
         route,
@@ -220,7 +240,7 @@ export function detailComponentWithResolver<
             const result$ = dataService
                 .query(config.query, { id })
                 .stream$.pipe(takeUntil(navigateAway$), shareReplay(1));
-            const entity$ = result$.pipe(map(result => config.getEntity(result as any)));
+            const entity$ = result$.pipe(map(result => result[config.entityKey]));
             const entityStream$ = entity$.pipe(
                 switchMap(raw => entity$),
                 filter(notNullOrUndefined),

+ 5 - 1
packages/admin-ui/src/lib/core/src/common/component-registry-types.ts

@@ -56,6 +56,7 @@ export type PageLocationId =
     | 'customer-detail'
     | 'customer-list'
     | 'customer-group-list'
+    | 'customer-group-detail'
     | 'facet-detail'
     | 'facet-list'
     | 'global-setting-detail'
@@ -80,6 +81,7 @@ export type PageLocationId =
     | 'tax-category-list'
     | 'tax-rate-detail'
     | 'tax-rate-list'
+    | 'zone-detail'
     | 'zone-list';
 
 /**
@@ -103,6 +105,7 @@ export type CustomDetailComponentLocationId =
     | 'collection-detail'
     | 'country-detail'
     | 'customer-detail'
+    | 'customer-group-detail'
     | 'facet-detail'
     | 'global-settings-detail'
     | 'order-detail'
@@ -113,6 +116,7 @@ export type CustomDetailComponentLocationId =
     | 'seller-detail'
     | 'shipping-method-detail'
     | 'tax-category-detail'
-    | 'tax-rate-detail';
+    | 'tax-rate-detail'
+    | 'zone-detail';
 
 export type UIExtensionLocationId = ActionBarLocationId | CustomDetailComponentLocationId;

Разница между файлами не показана из-за своего большого размера
+ 123 - 149
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 2 - 2
packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts

@@ -28,7 +28,7 @@ export function createUpdatedTranslatable<T extends { translations: any[] } & Ma
     const { translatable, updatedFields, languageCode, customFieldConfig, defaultTranslation } = options;
     const currentTranslation =
         findTranslation(translatable, languageCode) || defaultTranslation || ({} as any);
-    const index = translatable.translations.indexOf(currentTranslation);
+    const index = translatable.translations?.indexOf(currentTranslation);
     const newTranslation = patchObject(currentTranslation, updatedFields);
     const newCustomFields: CustomFieldsObject = {};
     const newTranslatedCustomFields: CustomFieldsObject = {};
@@ -46,7 +46,7 @@ export function createUpdatedTranslatable<T extends { translations: any[] } & Ma
     }
     const newTranslatable = {
         ...(patchObject(translatable, updatedFields) as any),
-        ...{ translations: translatable.translations.slice() },
+        ...{ translations: translatable.translations?.slice() ?? [] },
     };
     if (customFieldConfig) {
         newTranslatable.customFields = newCustomFields;

+ 1 - 1
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html

@@ -2,7 +2,7 @@
     <div class="left-nav">
         <div class="branding py-4 px-4">
             <a [routerLink]="['/']"
-                ><img src="assets/logo.webp" class="logo" /></a
+                ><img src="assets/logo.svg" class="logo" style="max-width: 100px" /></a
             >
         </div>
         <div class="mx-4">

+ 0 - 30
packages/admin-ui/src/lib/core/src/data/definitions/administrator-definitions.ts

@@ -36,18 +36,6 @@ export const ADMINISTRATOR_FRAGMENT = gql`
     ${ROLE_FRAGMENT}
 `;
 
-export const GET_ADMINISTRATORS = gql`
-    query GetAdministrators($options: AdministratorListOptions) {
-        administrators(options: $options) {
-            items {
-                ...Administrator
-            }
-            totalItems
-        }
-    }
-    ${ADMINISTRATOR_FRAGMENT}
-`;
-
 export const GET_ACTIVE_ADMINISTRATOR = gql`
     query GetActiveAdministrator {
         activeAdministrator {
@@ -57,15 +45,6 @@ export const GET_ACTIVE_ADMINISTRATOR = gql`
     ${ADMINISTRATOR_FRAGMENT}
 `;
 
-export const GET_ADMINISTRATOR = gql`
-    query GetAdministrator($id: ID!) {
-        administrator(id: $id) {
-            ...Administrator
-        }
-    }
-    ${ADMINISTRATOR_FRAGMENT}
-`;
-
 export const CREATE_ADMINISTRATOR = gql`
     mutation CreateAdministrator($input: CreateAdministratorInput!) {
         createAdministrator(input: $input) {
@@ -123,15 +102,6 @@ export const GET_ROLES = gql`
     ${ROLE_FRAGMENT}
 `;
 
-export const GET_ROLE = gql`
-    query GetRole($id: ID!) {
-        role(id: $id) {
-            ...Role
-        }
-    }
-    ${ROLE_FRAGMENT}
-`;
-
 export const CREATE_ROLE = gql`
     mutation CreateRole($input: CreateRoleInput!) {
         createRole(input: $input) {

+ 0 - 9
packages/admin-ui/src/lib/core/src/data/definitions/collection-definitions.ts

@@ -94,15 +94,6 @@ export const GET_COLLECTION_LIST = gql`
     ${COLLECTION_FOR_LIST_FRAGMENT}
 `;
 
-export const GET_COLLECTION = gql`
-    query GetCollection($id: ID!) {
-        collection(id: $id) {
-            ...Collection
-        }
-    }
-    ${COLLECTION_FRAGMENT}
-`;
-
 export const CREATE_COLLECTION = gql`
     mutation CreateCollection($input: CreateCollectionInput!) {
         createCollection(input: $input) {

+ 3 - 24
packages/admin-ui/src/lib/core/src/data/definitions/customer-definitions.ts

@@ -78,30 +78,6 @@ export const GET_CUSTOMER_LIST = gql`
     }
 `;
 
-export const GET_CUSTOMER = gql`
-    query GetCustomer($id: ID!, $orderListOptions: OrderListOptions) {
-        customer(id: $id) {
-            ...Customer
-            groups {
-                id
-                name
-            }
-            orders(options: $orderListOptions) {
-                items {
-                    id
-                    code
-                    state
-                    totalWithTax
-                    currencyCode
-                    updatedAt
-                }
-                totalItems
-            }
-        }
-    }
-    ${CUSTOMER_FRAGMENT}
-`;
-
 export const CREATE_CUSTOMER = gql`
     mutation CreateCustomer($input: CreateCustomerInput!, $password: String) {
         createCustomer(input: $input, password: $password) {
@@ -228,6 +204,9 @@ export const GET_CUSTOMER_GROUP_WITH_CUSTOMERS = gql`
                     emailAddress
                     firstName
                     lastName
+                    user {
+                        id
+                    }
                 }
                 totalItems
             }

+ 0 - 21
packages/admin-ui/src/lib/core/src/data/definitions/facet-definitions.ts

@@ -106,18 +106,6 @@ export const DELETE_FACET_VALUES = gql`
     }
 `;
 
-export const GET_FACET_LIST = gql`
-    query GetFacetList($options: FacetListOptions) {
-        facets(options: $options) {
-            items {
-                ...FacetWithValues
-            }
-            totalItems
-        }
-    }
-    ${FACET_WITH_VALUES_FRAGMENT}
-`;
-
 export const GET_FACET_VALUE_LIST = gql`
     query GetFacetValueList($options: FacetValueListOptions) {
         facetValues(options: $options) {
@@ -130,15 +118,6 @@ export const GET_FACET_VALUE_LIST = gql`
     ${FACET_VALUE_FRAGMENT}
 `;
 
-export const GET_FACET_WITH_VALUES = gql`
-    query GetFacetWithValues($id: ID!) {
-        facet(id: $id) {
-            ...FacetWithValues
-        }
-    }
-    ${FACET_WITH_VALUES_FRAGMENT}
-`;
-
 export const ASSIGN_FACETS_TO_CHANNEL = gql`
     mutation AssignFacetsToChannel($input: AssignFacetsToChannelInput!) {
         assignFacetsToChannel(input: $input) {

+ 0 - 21
packages/admin-ui/src/lib/core/src/data/definitions/promotion-definitions.ts

@@ -34,27 +34,6 @@ export const PROMOTION_FRAGMENT = gql`
     ${CONFIGURABLE_OPERATION_FRAGMENT}
 `;
 
-export const GET_PROMOTION_LIST = gql`
-    query GetPromotionList($options: PromotionListOptions) {
-        promotions(options: $options) {
-            items {
-                ...Promotion
-            }
-            totalItems
-        }
-    }
-    ${PROMOTION_FRAGMENT}
-`;
-
-export const GET_PROMOTION = gql`
-    query GetPromotion($id: ID!) {
-        promotion(id: $id) {
-            ...Promotion
-        }
-    }
-    ${PROMOTION_FRAGMENT}
-`;
-
 export const GET_ADJUSTMENT_OPERATIONS = gql`
     query GetAdjustmentOperations {
         promotionConditions {

+ 0 - 115
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -22,23 +22,6 @@ export const COUNTRY_FRAGMENT = gql`
     }
 `;
 
-export const GET_COUNTRY_LIST = gql`
-    query GetCountryList($options: CountryListOptions) {
-        countries(options: $options) {
-            items {
-                id
-                createdAt
-                updatedAt
-                code
-                name
-                type
-                enabled
-            }
-            totalItems
-        }
-    }
-`;
-
 export const GET_AVAILABLE_COUNTRIES = gql`
     query GetAvailableCountries {
         countries(options: { filter: { enabled: { eq: true } } }) {
@@ -52,15 +35,6 @@ export const GET_AVAILABLE_COUNTRIES = gql`
     }
 `;
 
-export const GET_COUNTRY = gql`
-    query GetCountry($id: ID!) {
-        country(id: $id) {
-            ...Country
-        }
-    }
-    ${COUNTRY_FRAGMENT}
-`;
-
 export const CREATE_COUNTRY = gql`
     mutation CreateCountry($input: CreateCountryInput!) {
         createCountry(input: $input) {
@@ -110,26 +84,6 @@ export const ZONE_FRAGMENT = gql`
     ${COUNTRY_FRAGMENT}
 `;
 
-export const GET_ZONE_LIST = gql`
-    query GetZoneList($options: ZoneListOptions) {
-        zones(options: $options) {
-            items {
-                ...Zone
-                members {
-                    createdAt
-                    updatedAt
-                    id
-                    name
-                    code
-                    enabled
-                }
-            }
-            totalItems
-        }
-    }
-    ${ZONE_FRAGMENT}
-`;
-
 export const GET_ZONE = gql`
     query GetZone($id: ID!) {
         zone(id: $id) {
@@ -215,15 +169,6 @@ export const GET_TAX_CATEGORIES = gql`
     ${TAX_CATEGORY_FRAGMENT}
 `;
 
-export const GET_TAX_CATEGORY = gql`
-    query GetTaxCategory($id: ID!) {
-        taxCategory(id: $id) {
-            ...TaxCategory
-        }
-    }
-    ${TAX_CATEGORY_FRAGMENT}
-`;
-
 export const CREATE_TAX_CATEGORY = gql`
     mutation CreateTaxCategory($input: CreateTaxCategoryInput!) {
         createTaxCategory(input: $input) {
@@ -283,18 +228,6 @@ export const TAX_RATE_FRAGMENT = gql`
     }
 `;
 
-export const GET_TAX_RATE_LIST = gql`
-    query GetTaxRateList($options: TaxRateListOptions) {
-        taxRates(options: $options) {
-            items {
-                ...TaxRate
-            }
-            totalItems
-        }
-    }
-    ${TAX_RATE_FRAGMENT}
-`;
-
 export const GET_TAX_RATE_LIST_SIMPLE = gql`
     query GetTaxRateListSimple($options: TaxRateListOptions) {
         taxRates(options: $options) {
@@ -319,15 +252,6 @@ export const GET_TAX_RATE_LIST_SIMPLE = gql`
     }
 `;
 
-export const GET_TAX_RATE = gql`
-    query GetTaxRate($id: ID!) {
-        taxRate(id: $id) {
-            ...TaxRate
-        }
-    }
-    ${TAX_RATE_FRAGMENT}
-`;
-
 export const CREATE_TAX_RATE = gql`
     mutation CreateTaxRate($input: CreateTaxRateInput!) {
         createTaxRate(input: $input) {
@@ -410,15 +334,6 @@ export const GET_CHANNELS = gql`
     ${CHANNEL_FRAGMENT}
 `;
 
-export const GET_CHANNEL = gql`
-    query GetChannel($id: ID!) {
-        channel(id: $id) {
-            ...Channel
-        }
-    }
-    ${CHANNEL_FRAGMENT}
-`;
-
 export const GET_SELLERS = gql`
     query GetSellers($options: SellerListOptions) {
         sellers(options: $options) {
@@ -431,15 +346,6 @@ export const GET_SELLERS = gql`
     ${SELLER_FRAGMENT}
 `;
 
-export const GET_SELLER = gql`
-    query GetSeller($id: ID!) {
-        seller(id: $id) {
-            ...Seller
-        }
-    }
-    ${SELLER_FRAGMENT}
-`;
-
 export const CREATE_SELLER = gql`
     mutation CreateSeller($input: CreateSellerInput!) {
         createSeller(input: $input) {
@@ -550,18 +456,6 @@ export const PAYMENT_METHOD_FRAGMENT = gql`
     ${CONFIGURABLE_OPERATION_FRAGMENT}
 `;
 
-export const GET_PAYMENT_METHOD_LIST = gql`
-    query GetPaymentMethodList($options: PaymentMethodListOptions!) {
-        paymentMethods(options: $options) {
-            items {
-                ...PaymentMethod
-            }
-            totalItems
-        }
-    }
-    ${PAYMENT_METHOD_FRAGMENT}
-`;
-
 export const GET_PAYMENT_METHOD_OPERATIONS = gql`
     query GetPaymentMethodOperations {
         paymentMethodEligibilityCheckers {
@@ -574,15 +468,6 @@ export const GET_PAYMENT_METHOD_OPERATIONS = gql`
     ${CONFIGURABLE_OPERATION_DEF_FRAGMENT}
 `;
 
-export const GET_PAYMENT_METHOD = gql`
-    query GetPaymentMethod($id: ID!) {
-        paymentMethod(id: $id) {
-            ...PaymentMethod
-        }
-    }
-    ${PAYMENT_METHOD_FRAGMENT}
-`;
-
 export const CREATE_PAYMENT_METHOD = gql`
     mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) {
         createPaymentMethod(input: $input) {

+ 0 - 21
packages/admin-ui/src/lib/core/src/data/definitions/shipping-definitions.ts

@@ -27,27 +27,6 @@ export const SHIPPING_METHOD_FRAGMENT = gql`
     ${CONFIGURABLE_OPERATION_FRAGMENT}
 `;
 
-export const GET_SHIPPING_METHOD_LIST = gql`
-    query GetShippingMethodList($options: ShippingMethodListOptions) {
-        shippingMethods(options: $options) {
-            items {
-                ...ShippingMethod
-            }
-            totalItems
-        }
-    }
-    ${SHIPPING_METHOD_FRAGMENT}
-`;
-
-export const GET_SHIPPING_METHOD = gql`
-    query GetShippingMethod($id: ID!) {
-        shippingMethod(id: $id) {
-            ...ShippingMethod
-        }
-    }
-    ${SHIPPING_METHOD_FRAGMENT}
-`;
-
 export const GET_SHIPPING_METHOD_OPERATIONS = gql`
     query GetShippingMethodOperations {
         shippingEligibilityCheckers {

+ 0 - 32
packages/admin-ui/src/lib/core/src/data/providers/administrator-data.service.ts

@@ -1,5 +1,3 @@
-import { FetchPolicy } from '@apollo/client';
-
 import * as Codegen from '../../common/generated-types';
 import {
     CREATE_ADMINISTRATOR,
@@ -9,9 +7,6 @@ import {
     DELETE_ROLE,
     DELETE_ROLES,
     GET_ACTIVE_ADMINISTRATOR,
-    GET_ADMINISTRATOR,
-    GET_ADMINISTRATORS,
-    GET_ROLE,
     GET_ROLES,
     UPDATE_ACTIVE_ADMINISTRATOR,
     UPDATE_ADMINISTRATOR,
@@ -23,31 +18,10 @@ import { BaseDataService } from './base-data.service';
 export class AdministratorDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getAdministrators(take: number = 10, skip: number = 0) {
-        return this.baseDataService.query<
-            Codegen.GetAdministratorsQuery,
-            Codegen.GetAdministratorsQueryVariables
-        >(GET_ADMINISTRATORS, {
-            options: {
-                take,
-                skip,
-            },
-        });
-    }
-
     getActiveAdministrator() {
         return this.baseDataService.query<Codegen.GetActiveAdministratorQuery>(GET_ACTIVE_ADMINISTRATOR, {});
     }
 
-    getAdministrator(id: string) {
-        return this.baseDataService.query<
-            Codegen.GetAdministratorQuery,
-            Codegen.GetAdministratorQueryVariables
-        >(GET_ADMINISTRATOR, {
-            id,
-        });
-    }
-
     createAdministrator(input: Codegen.CreateAdministratorInput) {
         return this.baseDataService.mutate<
             Codegen.CreateAdministratorMutation,
@@ -92,12 +66,6 @@ export class AdministratorDataService {
         });
     }
 
-    getRole(id: string) {
-        return this.baseDataService.query<Codegen.GetRoleQuery, Codegen.GetRoleQueryVariables>(GET_ROLE, {
-            id,
-        });
-    }
-
     createRole(input: Codegen.CreateRoleInput) {
         return this.baseDataService.mutate<Codegen.CreateRoleMutation, Codegen.CreateRoleMutationVariables>(
             CREATE_ROLE,

+ 0 - 10
packages/admin-ui/src/lib/core/src/data/providers/collection-data.service.ts

@@ -8,7 +8,6 @@ import {
     CREATE_COLLECTION,
     DELETE_COLLECTION,
     DELETE_COLLECTIONS,
-    GET_COLLECTION,
     GET_COLLECTION_CONTENTS,
     GET_COLLECTION_FILTERS,
     GET_COLLECTION_LIST,
@@ -36,15 +35,6 @@ export class CollectionDataService {
         });
     }
 
-    getCollection(id: string) {
-        return this.baseDataService.query<Codegen.GetCollectionQuery, Codegen.GetCollectionQueryVariables>(
-            GET_COLLECTION,
-            {
-                id,
-            },
-        );
-    }
-
     createCollection(input: Codegen.CreateCollectionInput) {
         return this.baseDataService.mutate<
             Codegen.CreateCollectionMutation,

+ 5 - 16
packages/admin-ui/src/lib/core/src/data/providers/customer-data.service.ts

@@ -1,5 +1,5 @@
-import { LogicalOperator } from '../../common/generated-types';
 import * as Codegen from '../../common/generated-types';
+import { LogicalOperator } from '../../common/generated-types';
 import {
     ADD_CUSTOMERS_TO_GROUP,
     ADD_NOTE_TO_CUSTOMER,
@@ -9,10 +9,11 @@ import {
     DELETE_CUSTOMER,
     DELETE_CUSTOMER_ADDRESS,
     DELETE_CUSTOMER_GROUP,
+    DELETE_CUSTOMER_GROUPS,
     DELETE_CUSTOMER_NOTE,
-    GET_CUSTOMER,
-    GET_CUSTOMER_GROUPS,
+    DELETE_CUSTOMERS,
     GET_CUSTOMER_GROUP_WITH_CUSTOMERS,
+    GET_CUSTOMER_GROUPS,
     GET_CUSTOMER_HISTORY,
     GET_CUSTOMER_LIST,
     REMOVE_CUSTOMERS_FROM_GROUP,
@@ -20,8 +21,6 @@ import {
     UPDATE_CUSTOMER_ADDRESS,
     UPDATE_CUSTOMER_GROUP,
     UPDATE_CUSTOMER_NOTE,
-    DELETE_CUSTOMERS,
-    DELETE_CUSTOMER_GROUPS,
 } from '../definitions/customer-definitions';
 
 import { BaseDataService } from './base-data.service';
@@ -55,17 +54,7 @@ export class CustomerDataService {
         });
     }
 
-    getCustomer(id: string, orderListOptions?: Codegen.OrderListOptions) {
-        return this.baseDataService.query<Codegen.GetCustomerQuery, Codegen.GetCustomerQueryVariables>(
-            GET_CUSTOMER,
-            {
-                id,
-                orderListOptions,
-            },
-        );
-    }
-
-    createCustomer(input: Codegen.CreateCustomerInput, password?: string) {
+    createCustomer(input: Codegen.CreateCustomerInput, password?: string | null) {
         return this.baseDataService.mutate<
             Codegen.CreateCustomerMutation,
             Codegen.CreateCustomerMutationVariables

+ 14 - 42
packages/admin-ui/src/lib/core/src/data/providers/facet-data.service.ts

@@ -7,11 +7,9 @@ import {
     CREATE_FACET,
     CREATE_FACET_VALUES,
     DELETE_FACET,
-    DELETE_FACETS,
     DELETE_FACET_VALUES,
-    GET_FACET_LIST,
+    DELETE_FACETS,
     GET_FACET_VALUE_LIST,
-    GET_FACET_WITH_VALUES,
     REMOVE_FACETS_FROM_CHANNEL,
     UPDATE_FACET,
     UPDATE_FACET_VALUES,
@@ -22,40 +20,11 @@ import { BaseDataService } from './base-data.service';
 export class FacetDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getFacets(take: number = 10, skip: number = 0) {
-        return this.baseDataService.query<Codegen.GetFacetListQuery, Codegen.GetFacetListQueryVariables>(
-            GET_FACET_LIST,
-            {
-                options: {
-                    take,
-                    skip,
-                },
-            },
-        );
-    }
-
     getFacetValues(options: Codegen.FacetValueListOptions, fetchPolicy?: WatchQueryFetchPolicy) {
-        return this.baseDataService.query<Codegen.GetFacetValueListQuery, Codegen.GetFacetValueListQueryVariables>(
-            GET_FACET_VALUE_LIST,
-            { options },
-            fetchPolicy,
-        );
-    }
-
-    getAllFacets() {
-        return this.baseDataService.query<Codegen.GetFacetListQuery, Codegen.GetFacetListQueryVariables>(
-            GET_FACET_LIST,
-            {},
-        );
-    }
-
-    getFacet(id: string) {
         return this.baseDataService.query<
-            Codegen.GetFacetWithValuesQuery,
-            Codegen.GetFacetWithValuesQueryVariables
-        >(GET_FACET_WITH_VALUES, {
-            id,
-        });
+            Codegen.GetFacetValueListQuery,
+            Codegen.GetFacetValueListQueryVariables
+        >(GET_FACET_VALUE_LIST, { options }, fetchPolicy);
     }
 
     createFacet(facet: Codegen.CreateFacetInput) {
@@ -89,7 +58,10 @@ export class FacetDataService {
     }
 
     deleteFacets(ids: string[], force: boolean) {
-        return this.baseDataService.mutate<Codegen.DeleteFacetsMutation, Codegen.DeleteFacetsMutationVariables>(DELETE_FACETS, {
+        return this.baseDataService.mutate<
+            Codegen.DeleteFacetsMutation,
+            Codegen.DeleteFacetsMutationVariables
+        >(DELETE_FACETS, {
             ids,
             force,
         });
@@ -126,12 +98,12 @@ export class FacetDataService {
     }
 
     assignFacetsToChannel(input: Codegen.AssignFacetsToChannelInput) {
-        return this.baseDataService.mutate<Codegen.AssignFacetsToChannelMutation, Codegen.AssignFacetsToChannelMutationVariables>(
-            ASSIGN_FACETS_TO_CHANNEL,
-            {
-                input,
-            },
-        );
+        return this.baseDataService.mutate<
+            Codegen.AssignFacetsToChannelMutation,
+            Codegen.AssignFacetsToChannelMutationVariables
+        >(ASSIGN_FACETS_TO_CHANNEL, {
+            input,
+        });
     }
 
     removeFacetsFromChannel(input: Codegen.RemoveFacetsFromChannelInput) {

+ 0 - 24
packages/admin-ui/src/lib/core/src/data/providers/promotion-data.service.ts

@@ -6,8 +6,6 @@ import {
     DELETE_PROMOTION,
     DELETE_PROMOTIONS,
     GET_ADJUSTMENT_OPERATIONS,
-    GET_PROMOTION,
-    GET_PROMOTION_LIST,
     UPDATE_PROMOTION,
 } from '../definitions/promotion-definitions';
 
@@ -16,28 +14,6 @@ import { BaseDataService } from './base-data.service';
 export class PromotionDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getPromotions(take: number = 10, skip: number = 0, filter?: Codegen.PromotionFilterParameter) {
-        return this.baseDataService.query<
-            Codegen.GetPromotionListQuery,
-            Codegen.GetPromotionListQueryVariables
-        >(GET_PROMOTION_LIST, {
-            options: {
-                take,
-                skip,
-                filter,
-            },
-        });
-    }
-
-    getPromotion(id: string) {
-        return this.baseDataService.query<Codegen.GetPromotionQuery, Codegen.GetPromotionQueryVariables>(
-            GET_PROMOTION,
-            {
-                id,
-            },
-        );
-    }
-
     getPromotionActionsAndConditions() {
         return this.baseDataService.query<Codegen.GetAdjustmentOperationsQuery>(GET_ADJUSTMENT_OPERATIONS);
     }

+ 10 - 119
packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts

@@ -20,34 +20,32 @@ import {
     CREATE_TAX_RATE,
     CREATE_ZONE,
     DELETE_CHANNEL,
+    DELETE_CHANNELS,
+    DELETE_COUNTRIES,
     DELETE_COUNTRY,
     DELETE_PAYMENT_METHOD,
+    DELETE_PAYMENT_METHODS,
     DELETE_SELLER,
+    DELETE_SELLERS,
+    DELETE_TAX_CATEGORIES,
     DELETE_TAX_CATEGORY,
     DELETE_TAX_RATE,
+    DELETE_TAX_RATES,
     DELETE_ZONE,
+    DELETE_ZONES,
     GET_ACTIVE_CHANNEL,
     GET_AVAILABLE_COUNTRIES,
-    GET_CHANNEL,
     GET_CHANNELS,
-    GET_COUNTRY,
-    GET_COUNTRY_LIST,
     GET_GLOBAL_SETTINGS,
-    GET_JOBS_BY_ID,
-    GET_JOBS_LIST,
     GET_JOB_INFO,
     GET_JOB_QUEUE_LIST,
-    GET_PAYMENT_METHOD,
-    GET_PAYMENT_METHOD_LIST,
+    GET_JOBS_BY_ID,
+    GET_JOBS_LIST,
     GET_PAYMENT_METHOD_OPERATIONS,
-    GET_SELLER,
     GET_SELLERS,
     GET_TAX_CATEGORIES,
-    GET_TAX_CATEGORY,
-    GET_TAX_RATE,
-    GET_TAX_RATE_LIST,
     GET_TAX_RATE_LIST_SIMPLE,
-    GET_ZONE_LIST,
+    GET_ZONE,
     REMOVE_MEMBERS_FROM_ZONE,
     UPDATE_CHANNEL,
     UPDATE_COUNTRY,
@@ -57,14 +55,6 @@ import {
     UPDATE_TAX_CATEGORY,
     UPDATE_TAX_RATE,
     UPDATE_ZONE,
-    DELETE_SELLERS,
-    DELETE_CHANNELS,
-    DELETE_PAYMENT_METHODS,
-    DELETE_TAX_CATEGORIES,
-    DELETE_TAX_RATES,
-    DELETE_COUNTRIES,
-    GET_ZONE,
-    DELETE_ZONES,
 } from '../definitions/settings-definitions';
 
 import { BaseDataService } from './base-data.service';
@@ -72,32 +62,10 @@ import { BaseDataService } from './base-data.service';
 export class SettingsDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getCountries(take: number = 10, skip: number = 0, filterTerm?: string) {
-        return this.baseDataService.query<
-            Codegen.GetCountryListQuery,
-            Codegen.GetCollectionListQueryVariables
-        >(GET_COUNTRY_LIST, {
-            options: {
-                take,
-                skip,
-                filter: {
-                    name: filterTerm ? { contains: filterTerm } : null,
-                },
-            },
-        });
-    }
-
     getAvailableCountries() {
         return this.baseDataService.query<Codegen.GetAvailableCountriesQuery>(GET_AVAILABLE_COUNTRIES);
     }
 
-    getCountry(id: string) {
-        return this.baseDataService.query<Codegen.GetCountryQuery, Codegen.GetCountryQueryVariables>(
-            GET_COUNTRY,
-            { id },
-        );
-    }
-
     createCountry(input: Codegen.CreateCountryInput) {
         return this.baseDataService.mutate<
             Codegen.CreateCountryMutation,
@@ -134,13 +102,6 @@ export class SettingsDataService {
         });
     }
 
-    getZones(options?: Codegen.ZoneListOptions) {
-        return this.baseDataService.query<Codegen.GetZoneListQuery, Codegen.GetZoneListQueryVariables>(
-            GET_ZONE_LIST,
-            { options },
-        );
-    }
-
     getZone(id: string) {
         return this.baseDataService.query<Codegen.GetZoneQuery, Codegen.GetZoneQueryVariables>(GET_ZONE, {
             id,
@@ -212,15 +173,6 @@ export class SettingsDataService {
         });
     }
 
-    getTaxCategory(id: string) {
-        return this.baseDataService.query<Codegen.GetTaxCategoryQuery, Codegen.GetTaxCategoryQueryVariables>(
-            GET_TAX_CATEGORY,
-            {
-                id,
-            },
-        );
-    }
-
     createTaxCategory(input: Codegen.CreateTaxCategoryInput) {
         return this.baseDataService.mutate<
             Codegen.CreateTaxCategoryMutation,
@@ -257,19 +209,6 @@ export class SettingsDataService {
         });
     }
 
-    getTaxRates(take: number = 10, skip: number = 0, fetchPolicy?: FetchPolicy) {
-        return this.baseDataService.query<Codegen.GetTaxRateListQuery, Codegen.GetTaxRateListQueryVariables>(
-            GET_TAX_RATE_LIST,
-            {
-                options: {
-                    take,
-                    skip,
-                },
-            },
-            fetchPolicy,
-        );
-    }
-
     getTaxRatesSimple(take: number = 10, skip: number = 0, fetchPolicy?: FetchPolicy) {
         return this.baseDataService.query<
             Codegen.GetTaxRateListSimpleQuery,
@@ -286,15 +225,6 @@ export class SettingsDataService {
         );
     }
 
-    getTaxRate(id: string) {
-        return this.baseDataService.query<Codegen.GetTaxRateQuery, Codegen.GetTaxRateQueryVariables>(
-            GET_TAX_RATE,
-            {
-                id,
-            },
-        );
-    }
-
     createTaxRate(input: Codegen.CreateTaxRateInput) {
         return this.baseDataService.mutate<
             Codegen.CreateTaxRateMutation,
@@ -338,15 +268,6 @@ export class SettingsDataService {
         );
     }
 
-    getChannel(id: string) {
-        return this.baseDataService.query<Codegen.GetChannelQuery, Codegen.GetChannelQueryVariables>(
-            GET_CHANNEL,
-            {
-                id,
-            },
-        );
-    }
-
     getSellerList(options?: SellerListOptions) {
         return this.baseDataService.query<Codegen.GetSellersQuery, Codegen.GetSellersQueryVariables>(
             GET_SELLERS,
@@ -354,15 +275,6 @@ export class SettingsDataService {
         );
     }
 
-    getSeller(id: string) {
-        return this.baseDataService.query<Codegen.GetSellerQuery, Codegen.GetSellerQueryVariables>(
-            GET_SELLER,
-            {
-                id,
-            },
-        );
-    }
-
     createSeller(input: Codegen.CreateSellerInput) {
         return this.baseDataService.mutate<
             Codegen.CreateSellerMutation,
@@ -442,27 +354,6 @@ export class SettingsDataService {
         });
     }
 
-    getPaymentMethods(take: number = 10, skip: number = 0) {
-        return this.baseDataService.query<
-            Codegen.GetPaymentMethodListQuery,
-            Codegen.GetPaymentMethodListQueryVariables
-        >(GET_PAYMENT_METHOD_LIST, {
-            options: {
-                skip,
-                take,
-            },
-        });
-    }
-
-    getPaymentMethod(id: string) {
-        return this.baseDataService.query<
-            Codegen.GetPaymentMethodQuery,
-            Codegen.GetPaymentMethodQueryVariables
-        >(GET_PAYMENT_METHOD, {
-            id,
-        });
-    }
-
     createPaymentMethod(input: Codegen.CreatePaymentMethodInput) {
         return this.baseDataService.mutate<
             Codegen.CreatePaymentMethodMutation,

+ 0 - 23
packages/admin-ui/src/lib/core/src/data/providers/shipping-method-data.service.ts

@@ -5,8 +5,6 @@ import {
     CREATE_SHIPPING_METHOD,
     DELETE_SHIPPING_METHOD,
     DELETE_SHIPPING_METHODS,
-    GET_SHIPPING_METHOD,
-    GET_SHIPPING_METHOD_LIST,
     GET_SHIPPING_METHOD_OPERATIONS,
     TEST_ELIGIBLE_SHIPPING_METHODS,
     TEST_SHIPPING_METHOD,
@@ -18,27 +16,6 @@ import { BaseDataService } from './base-data.service';
 export class ShippingMethodDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getShippingMethods(take: number = 10, skip: number = 0) {
-        return this.baseDataService.query<
-            Codegen.GetShippingMethodListQuery,
-            Codegen.GetShippingMethodListQueryVariables
-        >(GET_SHIPPING_METHOD_LIST, {
-            options: {
-                take,
-                skip,
-            },
-        });
-    }
-
-    getShippingMethod(id: string) {
-        return this.baseDataService.query<
-            Codegen.GetShippingMethodQuery,
-            Codegen.GetShippingMethodQueryVariables
-        >(GET_SHIPPING_METHOD, {
-            id,
-        });
-    }
-
     getShippingMethodOperations() {
         return this.baseDataService.query<Codegen.GetShippingMethodOperationsQuery>(
             GET_SHIPPING_METHOD_OPERATIONS,

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

@@ -45,7 +45,7 @@ export class PageService {
                 component = cmp;
                 route.resolve = { detail: config.component.resolveFn };
                 route.data = {
-                    breadcrumb: data => breadcrumbFn(data.detail.result),
+                    breadcrumb: data => data.detail.entity.pipe(map(entity => breadcrumbFn(entity))),
                 };
             } else {
                 component = config.component;

+ 15 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts

@@ -1,12 +1,27 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { gql } from 'apollo-angular';
 import { Observable, of } from 'rxjs';
 import { mergeMap } from 'rxjs/operators';
 
 import { GetAssetQuery, UpdateAssetInput } from '../../../common/generated-types';
+import { ASSET_FRAGMENT, TAG_FRAGMENT } from '../../../data/definitions/product-definitions';
 import { DataService } from '../../../data/providers/data.service';
 import { Dialog } from '../../../providers/modal/modal.types';
 import { AssetLike } from '../asset-gallery/asset-gallery.types';
 
+export const ASSET_PREVIEW_QUERY = gql`
+    query AssetPreviewQuery($id: ID!) {
+        asset(id: $id) {
+            ...Asset
+            tags {
+                ...Tag
+            }
+        }
+    }
+    ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-asset-preview-dialog',
     templateUrl: './asset-preview-dialog.component.html',

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-preview-links/asset-preview-links.component.scss

@@ -1,3 +1,5 @@
 .asset-preview-link {
     font-size: 12px;
+    display: flex;
+    align-items: center;
 }

+ 133 - 131
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html

@@ -1,139 +1,141 @@
-<div class="preview-image" #previewDiv [class.centered]="centered">
-    <div class="image-wrapper">
-        <vdr-focal-point-control
-            [width]="width"
-            [height]="height"
-            [fpx]="fpx"
-            [fpy]="fpy"
-            [editable]="settingFocalPoint"
-            (focalPointChange)="onFocalPointChange($event)"
-        >
-            <img
-                class="asset-image"
-                [src]="asset | assetPreview: size"
-                [ngClass]="size"
-                #imageElement
-                (load)="onImageLoad()"
-            />
-        </vdr-focal-point-control>
-        <div class="focal-point-info" *ngIf="settingFocalPoint">
-            <button class="icon-button" (click)="setFocalPointCancel()">
-                <clr-icon shape="times"></clr-icon>
-            </button>
-            <button class="btn btn-primary btn-sm" (click)="setFocalPointEnd()" [disabled]="!lastFocalPoint">
-                <clr-icon shape="crosshairs"></clr-icon>
-                {{ 'asset.set-focal-point' | translate }}
-            </button>
-        </div>
-    </div>
-</div>
-
-<div class="controls" [class.fade]="settingFocalPoint">
-    <form [formGroup]="form">
-        <clr-input-container class="name-input" *ngIf="editable">
-            <label>{{ 'common.name' | translate }}</label>
-            <input
-                clrInput
-                type="text"
-                formControlName="name"
-                [readonly]="!(['UpdateCatalog', 'UpdateAsset'] | hasPermission) || settingFocalPoint"
-            />
-        </clr-input-container>
-
-        <vdr-labeled-data [label]="'common.name' | translate" *ngIf="!editable">
-            <span class="elide">
-                {{ asset.name }}
-            </span>
-        </vdr-labeled-data>
-
-        <vdr-labeled-data [label]="'asset.source-file' | translate">
-            <a [href]="asset.source" [title]="asset.source" target="_blank" class="elide source-link">{{
-                getSourceFileName()
-            }}</a>
-        </vdr-labeled-data>
+<vdr-page-detail-layout>
+    <vdr-page-detail-sidebar>
+        <vdr-card>
+            <div *ngIf="!editable" class="edit-button-wrapper">
+                <a
+                    class="btn btn-link btn-sm"
+                    [routerLink]="['/catalog', 'assets', asset.id]"
+                    (click)="editClick.emit()"
+                >
+                    <clr-icon shape="edit"></clr-icon>
+                    {{ 'common.edit' | translate }}
+                </a>
+            </div>
+            <vdr-form-field *ngIf="editable" [label]="'common.name' | translate">
+                <input
+                    type="text"
+                    [formControl]="form.get('name')"
+                    [readonly]="!(['UpdateCatalog', 'UpdateAsset'] | hasPermission) || settingFocalPoint"
+                />
+            </vdr-form-field>
+            <vdr-labeled-data [label]="'common.name' | translate" *ngIf="!editable">
+                <span class="elide">
+                    {{ asset.name }}
+                </span>
+            </vdr-labeled-data>
+            <vdr-labeled-data [label]="'asset.source-file' | translate">
+                <a [href]="asset.source" [title]="asset.source" target="_blank" class="elide source-link">{{
+                    getSourceFileName()
+                }}</a>
+            </vdr-labeled-data>
 
-        <vdr-labeled-data [label]="'asset.original-asset-size' | translate">
-            {{ asset.fileSize | filesize }}
-        </vdr-labeled-data>
+            <vdr-labeled-data [label]="'asset.original-asset-size' | translate">
+                {{ asset.fileSize | filesize }}
+            </vdr-labeled-data>
 
-        <vdr-labeled-data [label]="'asset.dimensions' | translate">
-            {{ asset.width }} x {{ asset.height }}
-        </vdr-labeled-data>
+            <vdr-labeled-data [label]="'asset.dimensions' | translate">
+                {{ asset.width }} x {{ asset.height }}
+            </vdr-labeled-data>
 
-        <vdr-labeled-data [label]="'asset.focal-point' | translate">
-            <span *ngIf="fpx"
-                ><clr-icon shape="crosshairs"></clr-icon> x: {{ fpx | number: '1.2-2' }}, y:
-                {{ fpy | number: '1.2-2' }}</span
-            >
-            <span *ngIf="!fpx">{{ 'common.not-set' | translate }}</span>
-            <br />
-            <button
-                class="btn btn-secondary-outline btn-sm"
-                [disabled]="settingFocalPoint"
-                (click)="setFocalPointStart()"
-            >
-                <ng-container *ngIf="!fpx">{{ 'asset.set-focal-point' | translate }}</ng-container>
-                <ng-container *ngIf="fpx">{{ 'asset.update-focal-point' | translate }}</ng-container>
-            </button>
-            <button
-                class="btn btn-warning-outline btn-sm"
-                [disabled]="settingFocalPoint"
-                *ngIf="!!fpx"
-                (click)="removeFocalPoint()"
+            <vdr-labeled-data [label]="'asset.focal-point' | translate">
+                <span *ngIf="fpx"
+                    ><clr-icon shape="crosshairs"></clr-icon> x: {{ fpx | number : '1.2-2' }}, y:
+                    {{ fpy | number : '1.2-2' }}</span
+                >
+                <span *ngIf="!fpx">{{ 'common.not-set' | translate }}</span>
+                <div class="flex">
+                    <button
+                        class="button-small"
+                        [disabled]="settingFocalPoint"
+                        (click)="setFocalPointStart()"
+                    >
+                        <ng-container *ngIf="!fpx">{{ 'asset.set-focal-point' | translate }}</ng-container>
+                        <ng-container *ngIf="fpx">{{ 'asset.update-focal-point' | translate }}</ng-container>
+                    </button>
+                    <button
+                        class="button-small"
+                        [disabled]="settingFocalPoint"
+                        *ngIf="!!fpx"
+                        (click)="removeFocalPoint()"
+                    >
+                        {{ 'asset.unset-focal-point' | translate }}
+                    </button>
+                </div>
+            </vdr-labeled-data>
+            <vdr-labeled-data [label]="'common.tags' | translate">
+                <ng-container *ngIf="editable">
+                    <vdr-tag-selector [formControl]="form.get('tags')"></vdr-tag-selector>
+                    <button class="button-ghost" (click)="manageTags()">
+                        <clr-icon shape="tags"></clr-icon>
+                        {{ 'common.manage-tags' | translate }}
+                    </button>
+                </ng-container>
+                <div *ngIf="!editable">
+                    <vdr-chip *ngFor="let tag of asset.tags" [colorFrom]="tag.value">
+                        <clr-icon shape="tag" class="mr2"></clr-icon>
+                        {{ tag.value }}</vdr-chip
+                    >
+                </div>
+            </vdr-labeled-data>
+        </vdr-card>
+        <vdr-card *ngIf="customFields.length" [title]="'common.custom-fields' | translate">
+            <vdr-tabbed-custom-fields
+                entityName="Asset"
+                [compact]="true"
+                [customFields]="customFields"
+                [customFieldsFormGroup]="customFieldsForm"
+                [readonly]="!(['UpdateCatalog', 'UpdateAsset'] | hasPermission)"
+            ></vdr-tabbed-custom-fields>
+        </vdr-card>
+        <vdr-card [title]="'asset.preview' | translate">
+            <vdr-form-field>
+                <select name="options" [(ngModel)]="size" [disabled]="settingFocalPoint">
+                    <option value="tiny">tiny</option>
+                    <option value="thumb">thumb</option>
+                    <option value="small">small</option>
+                    <option value="medium">medium</option>
+                    <option value="large">large</option>
+                    <option value="">full size</option>
+                </select>
+            </vdr-form-field>
+            <div class="asset-detail">{{ width }} x {{ height }}</div>
+            <vdr-asset-preview-links [asset]="asset"></vdr-asset-preview-links>
+        </vdr-card>
+        <vdr-card>
+            <vdr-page-entity-info *ngIf="asset as entity" [entity]="entity"></vdr-page-entity-info>
+        </vdr-card>
+    </vdr-page-detail-sidebar>
+    <div class="preview-image" #previewDiv [class.centered]="centered">
+        <div class="image-wrapper">
+            <vdr-focal-point-control
+                [width]="width"
+                [height]="height"
+                [fpx]="fpx"
+                [fpy]="fpy"
+                [editable]="settingFocalPoint"
+                (focalPointChange)="onFocalPointChange($event)"
             >
-                {{ 'asset.unset-focal-point' | translate }}
-            </button>
-        </vdr-labeled-data>
-        <vdr-labeled-data [label]="'common.tags' | translate">
-            <ng-container *ngIf="editable">
-                <vdr-tag-selector formControlName="tags"></vdr-tag-selector>
-                <button class="btn btn-link btn-sm" (click)="manageTags()">
-                    <clr-icon shape="tags"></clr-icon>
-                    {{ 'common.manage-tags' | translate }}
+                <img
+                    class="asset-image"
+                    [src]="asset | assetPreview : size"
+                    [ngClass]="size"
+                    #imageElement
+                    (load)="onImageLoad()"
+                />
+            </vdr-focal-point-control>
+            <div class="focal-point-info" *ngIf="settingFocalPoint">
+                <button class="icon-button" (click)="setFocalPointCancel()">
+                    <clr-icon shape="times"></clr-icon>
                 </button>
-            </ng-container>
-            <div *ngIf="!editable">
-                <vdr-chip *ngFor="let tag of asset.tags" [colorFrom]="tag.value">
-                    <clr-icon shape="tag" class="mr2"></clr-icon>
-                    {{ tag.value }}</vdr-chip
+                <button
+                    class="btn btn-primary btn-sm"
+                    (click)="setFocalPointEnd()"
+                    [disabled]="!lastFocalPoint"
                 >
+                    <clr-icon shape="crosshairs"></clr-icon>
+                    {{ 'asset.set-focal-point' | translate }}
+                </button>
             </div>
-        </vdr-labeled-data>
-    </form>
-    <section *ngIf="customFields.length">
-        <label>{{ 'common.custom-fields' | translate }}</label>
-        <vdr-tabbed-custom-fields
-            entityName="Asset"
-            [compact]="true"
-            [customFields]="customFields"
-            [customFieldsFormGroup]="customFieldsForm"
-            [readonly]="!(['UpdateCatalog', 'UpdateAsset'] | hasPermission)"
-        ></vdr-tabbed-custom-fields>
-    </section>
-    <div class="flex-spacer"></div>
-    <div class="preview-select">
-        <clr-select-container>
-            <label>{{ 'asset.preview' | translate }}</label>
-            <select clrSelect name="options" [(ngModel)]="size" [disabled]="settingFocalPoint">
-                <option value="tiny">tiny</option>
-                <option value="thumb">thumb</option>
-                <option value="small">small</option>
-                <option value="medium">medium</option>
-                <option value="large">large</option>
-                <option value="">full size</option>
-            </select>
-        </clr-select-container>
-        <div class="asset-detail">{{ width }} x {{ height }}</div>
-    </div>
-    <vdr-asset-preview-links class="mb-4" [asset]="asset"></vdr-asset-preview-links>
-    <div *ngIf="!editable" class="edit-button-wrapper">
-        <a
-            class="btn btn-link btn-sm"
-            [routerLink]="['/catalog', 'assets', asset.id]"
-            (click)="editClick.emit()"
-        >
-            <clr-icon shape="edit"></clr-icon>
-            {{ 'common.edit' | translate }}
-        </a>
+        </div>
     </div>
-</div>
+</vdr-page-detail-layout>

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.scss

@@ -1,17 +1,18 @@
 
 :host {
-    display: flex;
     height: 100%;
 }
 
 .preview-image {
     width: 100%;
+    max-width: 800px;
     height: 100%;
     min-height: 60vh;
     overflow: auto;
     text-align: center;
     box-shadow: inset 0 0 5px 0 rgba(0, 0, 0, 0.1);
     background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACuoAAArqAVDM774AAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTZEaa/1AAAAK0lEQVQ4T2P4jwP8xgFGNSADqDwGIF0DlMYAUH0YYFQDMoDKYwASNfz/DwB/JvcficphowAAAABJRU5ErkJggg==');
+    margin-top: var(--space-unit);
     flex: 1;
 
     &.centered {

+ 8 - 7
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts

@@ -10,7 +10,7 @@ import {
     Output,
     ViewChild,
 } from '@angular/core';
-import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
+import { FormBuilder, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { fromEvent, Subscription } from 'rxjs';
 import { debounceTime } from 'rxjs/operators';
@@ -39,7 +39,10 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
     @Output() assetChange = new EventEmitter<Omit<UpdateAssetInput, 'focalPoint'>>();
     @Output() editClick = new EventEmitter();
 
-    form: UntypedFormGroup;
+    form = this.formBuilder.group({
+        name: '',
+        tags: [[] as string[]],
+    });
 
     size: PreviewPreset = 'medium';
     width = 0;
@@ -53,7 +56,7 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
     private sizePriorToSettingFocalPoint: PreviewPreset;
 
     constructor(
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         private dataService: DataService,
         private notificationService: NotificationService,
         private changeDetector: ChangeDetectorRef,
@@ -70,10 +73,8 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
 
     ngOnInit() {
         const { focalPoint } = this.asset;
-        this.form = this.formBuilder.group({
-            name: [this.asset.name],
-            tags: [this.asset.tags?.map(t => t.value)],
-        });
+        this.form.get('name')?.setValue(this.asset.name);
+        this.form.get('tags')?.setValue(this.asset.tags?.map(t => t.value));
         this.subscription = this.form.valueChanges.subscribe(value => {
             this.assetChange.emit({
                 id: this.asset.id,

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/chip/chip.component.scss

@@ -50,7 +50,8 @@
     padding: 5px 8px;
     white-space: nowrap;
     display: flex;
-    align-items: baseline;
+    align-items: center;
+    gap: 2px;
 }
 
 .chip-icon {

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

@@ -25,7 +25,8 @@
 }
 .table-wrapper {
     display: block;
-    overflow: auto;
+    overflow-y: hidden;
+    overflow-x: auto;
     width: 100%;
     max-width: var(--surface-width);
 }

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

@@ -14,6 +14,8 @@
     label {
         font-size: var(--font-size-sm);
         color: var(--color-weight-800);
+        margin-bottom: 4px;
+        display: inline-block;
     }
 }
 .tooltip-text {
@@ -28,6 +30,8 @@
     input,
     select,
     textarea,
+    vdr-zone-selector,
+    vdr-facet-value-selector,
     vdr-rich-text-editor {
         width: 100%;
     }

+ 3 - 1
packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.scss

@@ -1,4 +1,6 @@
-
+:host {
+    display: block;
+}
 .address-lines {
     list-style-type: none;
     line-height: 1.2em;

+ 11 - 0
packages/admin-ui/src/lib/core/src/shared/components/zone-selector/zone-selector.component.html

@@ -0,0 +1,11 @@
+<ng-select
+    [items]="zones$ | async"
+    [addTag]="false"
+    appendTo="body"
+    bindLabel="name"
+    bindValue="id"
+    [disabled]="disabled || readonly"
+    [ngModel]="value"
+    (change)="onChange($event)"
+>
+</ng-select>

+ 7 - 0
packages/admin-ui/src/lib/core/src/shared/components/zone-selector/zone-selector.component.scss

@@ -0,0 +1,7 @@
+@import "mixins";
+
+:host {
+    ::ng-deep {
+        @include ng-select-facet-values;
+    }
+}

+ 102 - 0
packages/admin-ui/src/lib/core/src/shared/components/zone-selector/zone-selector.component.ts

@@ -0,0 +1,102 @@
+import {
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    EventEmitter,
+    Input,
+    Output,
+    ViewChild,
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { NgSelectComponent } from '@ng-select/ng-select';
+import { GetZoneSelectorListDocument, GetZoneSelectorListQuery, ItemOf } from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
+import { Subject } from 'rxjs';
+import { DataService } from '../../../data/providers/data.service';
+
+export const GET_ZONE_SELECTOR_LIST = gql`
+    query GetZoneSelectorList($options: ZoneListOptions) {
+        zones(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+            }
+            totalItems
+        }
+    }
+`;
+
+type Zone = ItemOf<GetZoneSelectorListQuery, 'zones'>;
+
+/**
+ * @description
+ * A form control for selecting zones.
+ *
+ * @docsCategory components
+ */
+@Component({
+    selector: 'vdr-zone-selector',
+    templateUrl: './zone-selector.component.html',
+    styleUrls: ['./zone-selector.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: ZoneSelectorComponent,
+            multi: true,
+        },
+    ],
+})
+export class ZoneSelectorComponent implements ControlValueAccessor {
+    @Output() selectedValuesChange = new EventEmitter<Zone>();
+    @Input() readonly = false;
+    @Input() transformControlValueAccessorValue: (value: Zone) => any = value => value.id;
+    selectedId$ = new Subject<string>();
+
+    @ViewChild(NgSelectComponent) private ngSelect: NgSelectComponent;
+
+    onChangeFn: (val: any) => void;
+    onTouchFn: () => void;
+    disabled = false;
+    value: string | Zone;
+    zones$ = this.dataService
+        .query(GetZoneSelectorListDocument, { options: { take: 999 } }, 'cache-first')
+        .mapSingle(result => result.zones.items);
+
+    constructor(private dataService: DataService, private changeDetectorRef: ChangeDetectorRef) {}
+
+    onChange(selected: Zone) {
+        if (this.readonly) {
+            return;
+        }
+        this.selectedValuesChange.emit(selected);
+        if (this.onChangeFn) {
+            const transformedValue = this.transformControlValueAccessorValue(selected);
+            this.onChangeFn(transformedValue);
+        }
+    }
+
+    registerOnChange(fn: any) {
+        this.onChangeFn = fn;
+    }
+
+    registerOnTouched(fn: any) {
+        this.onTouchFn = fn;
+    }
+
+    setDisabledState(isDisabled: boolean): void {
+        this.disabled = isDisabled;
+    }
+
+    focus() {
+        this.ngSelect.focus();
+    }
+
+    writeValue(obj: string | Zone | null): void {
+        if (typeof obj === 'string' && obj.length > 0) {
+            this.value = obj;
+        }
+    }
+}

+ 15 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts

@@ -1,16 +1,31 @@
 import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 import { UntypedFormControl } from '@angular/forms';
 import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+import { gql } from 'apollo-angular';
 import { Observable, of } from 'rxjs';
 import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
 
 import { FormInputComponent } from '../../../../common/component-registry-types';
 import { GetAssetQuery, RelationCustomFieldConfig } from '../../../../common/generated-types';
+import { ASSET_FRAGMENT, TAG_FRAGMENT } from '../../../../data/definitions/product-definitions';
 import { DataService } from '../../../../data/providers/data.service';
 import { ModalService } from '../../../../providers/modal/modal.service';
 import { AssetPickerDialogComponent } from '../../../components/asset-picker-dialog/asset-picker-dialog.component';
 import { AssetPreviewDialogComponent } from '../../../components/asset-preview-dialog/asset-preview-dialog.component';
 
+export const RELATION_ASSET_INPUT_QUERY = gql`
+    query RelationAssetInputQuery($id: ID!) {
+        asset(id: $id) {
+            ...Asset
+            tags {
+                ...Tag
+            }
+        }
+    }
+    ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-relation-asset-input',
     templateUrl: './relation-asset-input.component.html',

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

@@ -165,6 +165,7 @@ 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 { CardComponent } from './components/card/card.component';
+import { ZoneSelectorComponent } from './components/zone-selector/zone-selector.component';
 
 const IMPORTS = [
     ClarityModule,
@@ -301,6 +302,7 @@ const DECLARATIONS = [
     PageDetailLayoutComponent,
     PageDetailSidebarComponent,
     CardComponent,
+    ZoneSelectorComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [

+ 227 - 171
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html

@@ -1,179 +1,235 @@
-<vdr-action-bar>
-    <vdr-ab-left>
-        <div class="flex clr-align-items-center">
-            <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-            <vdr-customer-status-label [customer]="entity$ | async"></vdr-customer-status-label>
-            <div
-                class="last-login"
-                *ngIf="(entity$ | async)?.user?.lastLogin as lastLogin"
-                [title]="lastLogin | localeDate: 'medium'"
-            >
-                {{ 'customer.last-login' | translate }}: {{ lastLogin | timeAgo }}
-            </div>
-        </div>
-    </vdr-ab-left>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left> </vdr-ab-left>
 
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="customer-detail"></vdr-action-bar-items>
-        <button
-            class="btn btn-primary"
-            *ngIf="isNew$ | async; else updateButton"
-            (click)="create()"
-            [disabled]="!(addressDefaultsUpdated || (detailForm.valid && detailForm.dirty))"
-        >
-            {{ 'common.create' | translate }}
-        </button>
-        <ng-template #updateButton>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="customer-detail"></vdr-action-bar-items>
             <button
-                *vdrIfPermissions="'UpdateCustomer'"
                 class="btn btn-primary"
-                (click)="save()"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
                 [disabled]="!(addressDefaultsUpdated || (detailForm.valid && detailForm.dirty))"
             >
-                {{ 'common.update' | translate }}
+                {{ 'common.create' | translate }}
             </button>
-        </ng-template>
-    </vdr-ab-right>
-</vdr-action-bar>
-
-<form class="form" [formGroup]="detailForm.get('customer')">
-    <vdr-form-field [label]="'customer.title' | translate" for="title" [readOnlyToggle]="!(isNew$ | async)">
-        <input id="title" type="text" formControlName="title" />
-    </vdr-form-field>
-    <vdr-form-field
-        [label]="'customer.first-name' | translate"
-        for="firstName"
-        [readOnlyToggle]="!(isNew$ | async)"
-    >
-        <input id="firstName" type="text" formControlName="firstName" />
-    </vdr-form-field>
-    <vdr-form-field
-        [label]="'customer.last-name' | translate"
-        for="lastName"
-        [readOnlyToggle]="!(isNew$ | async)"
-    >
-        <input id="lastName" type="text" formControlName="lastName" />
-    </vdr-form-field>
-    <vdr-form-field
-        [label]="'customer.email-address' | translate"
-        for="emailAddress"
-        [readOnlyToggle]="!(isNew$ | async)"
-    >
-        <input id="emailAddress" type="text" formControlName="emailAddress" />
-    </vdr-form-field>
-    <vdr-form-field
-        [label]="'customer.phone-number' | translate"
-        for="phoneNumber"
-        [readOnlyToggle]="!(isNew$ | async)"
-    >
-        <input id="phoneNumber" type="text" formControlName="phoneNumber" />
-    </vdr-form-field>
-    <vdr-form-field [label]="'customer.password' | translate" for="password" *ngIf="isNew$ | async">
-        <input id="password" type="password" formControlName="password" />
-    </vdr-form-field>
-
-    <section formGroupName="customFields" *ngIf="customFields.length">
-        <label>{{ 'common.custom-fields' | translate }}</label>
-        <vdr-tabbed-custom-fields
-            entityName="Customer"
-            [customFields]="customFields"
-            [customFieldsFormGroup]="detailForm.get(['customer', 'customFields'])"
-        ></vdr-tabbed-custom-fields>
-    </section>
-    <vdr-custom-detail-component-host
-        locationId="customer-detail"
-        [entity$]="entity$"
-        [detailForm]="detailForm"
-    ></vdr-custom-detail-component-host>
-</form>
-
-<div class="groups" *ngIf="(entity$ | async)?.groups as groups">
-    <label class="clr-control-label">{{ 'customer.customer-groups' | translate }}</label>
-    <ng-container *ngIf="groups.length; else noGroups">
-        <vdr-chip
-            *ngFor="let group of groups"
-            [colorFrom]="group.id"
-            icon="times"
-            (iconClick)="removeFromGroup(group)"
-            >{{ group.name }}</vdr-chip
-        >
-    </ng-container>
-    <ng-template #noGroups>
-        {{ 'customer.not-a-member-of-any-groups' | translate }}
-    </ng-template>
-    <div>
-        <button
-            class="btn btn-sm btn-secondary"
-            (click)="addToGroup()"
-            *vdrIfPermissions="'UpdateCustomerGroup'"
-        >
-            <clr-icon shape="plus"></clr-icon>
-            {{ 'customer.add-customer-to-group' | translate }}
-        </button>
-    </div>
-</div>
-
-<div class="clr-row" *ngIf="!(isNew$ | async)">
-    <div class="clr-col-md-4">
-        <h3>{{ 'customer.addresses' | translate }}</h3>
-        <vdr-address-card
-            *ngFor="let addressForm of getAddressFormControls()"
-            [class.to-delete]="addressesToDeleteIds.has(addressForm.value.id)"
-            [availableCountries]="availableCountries$ | async"
-            [isDefaultBilling]="defaultBillingAddressId === addressForm.value.id"
-            [isDefaultShipping]="defaultShippingAddressId === addressForm.value.id"
-            [addressForm]="addressForm"
-            [customFields]="addressCustomFields"
-            [editable]="(['UpdateCustomer'] | hasPermission) && !addressesToDeleteIds.has(addressForm.value.id)"
-            (setAsDefaultBilling)="setDefaultBillingAddressId($event)"
-            (setAsDefaultShipping)="setDefaultShippingAddressId($event)"
-            (deleteAddress)="toggleDeleteAddress($event)"
-        ></vdr-address-card>
-        <button class="btn btn-secondary" (click)="addAddress()" *vdrIfPermissions="'UpdateCustomer'">
-            <clr-icon shape="plus"></clr-icon>
-            {{ 'customer.create-new-address' | translate }}
-        </button>
-    </div>
-    <div class="clr-col-md-8">
-        <h3>{{ 'customer.orders' | translate }}</h3>
-        <vdr-data-table
-            [items]="orders$ | async"
-            [itemsPerPage]="ordersPerPage"
-            [totalItems]="ordersCount$ | async"
-            [currentPage]="currentOrdersPage"
-            [emptyStateLabel]="'customer.no-orders-placed' | translate"
-            (itemsPerPageChange)="setOrderItemsPerPage($event)"
-            (pageChange)="setOrderCurrentPage($event)"
+            <ng-template #updateButton>
+                <button
+                    *vdrIfPermissions="'UpdateCustomer'"
+                    class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="!(addressDefaultsUpdated || (detailForm.valid && detailForm.dirty))"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+<vdr-page-detail-layout>
+    <vdr-page-detail-sidebar>
+        <vdr-card *ngIf="entity$ | async as customer">
+            <vdr-customer-status-label [customer]="customer"></vdr-customer-status-label>
+            <vdr-labeled-data
+                class="last-login"
+                *ngIf="customer.user?.lastLogin as lastLogin"
+                [label]="'customer.last-login' | translate"
+            >
+                <time [dateTime]="lastLogin">{{ lastLogin | timeAgo }}</time>
+            </vdr-labeled-data>
+        </vdr-card>
+        <vdr-card
+            [title]="'customer.customer-groups' | translate"
+            *ngIf="(entity$ | async)?.groups as groups"
         >
-            <vdr-dt-column>{{ 'common.code' | translate }}</vdr-dt-column>
-            <vdr-dt-column>{{ 'order.state' | translate }}</vdr-dt-column>
-            <vdr-dt-column>{{ 'order.total' | translate }}</vdr-dt-column>
-            <vdr-dt-column>{{ 'common.updated-at' | translate }}</vdr-dt-column>
-            <vdr-dt-column></vdr-dt-column>
-            <ng-template let-order="item">
-                <td class="left">{{ order.code }}</td>
-                <td class="left">{{ order.state }}</td>
-                <td class="left">{{ order.totalWithTax | localeCurrency: order.currencyCode }}</td>
-                <td class="left">{{ order.updatedAt | localeDate: 'medium' }}</td>
-                <td class="right">
-                    <vdr-table-row-action
-                        iconShape="shopping-cart"
-                        [label]="'common.open' | translate"
-                        [linkTo]="['/orders/', order.id]"
-                    ></vdr-table-row-action>
-                </td>
+            <div *ngIf="groups.length; else noGroups">
+                <vdr-chip
+                    *ngFor="let group of groups"
+                    [colorFrom]="group.id"
+                    icon="times"
+                    (iconClick)="removeFromGroup(group)"
+                    >{{ group.name }}</vdr-chip
+                >
+            </div>
+            <ng-template #noGroups>
+                {{ 'customer.not-a-member-of-any-groups' | translate }}
             </ng-template>
-        </vdr-data-table>
-    </div>
-</div>
-<div class="clr-row" *ngIf="!(isNew$ | async)">
-    <div class="clr-col-md-6">
-        <vdr-customer-history
-            [customer]="entity$ | async"
-            [history]="history$ | async"
-            (addNote)="addNoteToCustomer($event)"
-            (updateNote)="updateNote($event)"
-            (deleteNote)="deleteNote($event)"
-        ></vdr-customer-history>
-    </div>
-</div>
+            <div>
+                <button
+                    class="btn btn-sm btn-secondary"
+                    (click)="addToGroup()"
+                    *vdrIfPermissions="'UpdateCustomerGroup'"
+                >
+                    <clr-icon shape="plus"></clr-icon>
+                    {{ 'customer.add-customer-to-group' | translate }}
+                </button>
+            </div>
+        </vdr-card>
+        <vdr-card>
+            <vdr-page-entity-info *ngIf="entity$ | async as entity" [entity]="entity" />
+        </vdr-card>
+    </vdr-page-detail-sidebar>
+    <vdr-page-block>
+        <form class="form" [formGroup]="detailForm.get('customer')">
+            <vdr-card>
+                <vdr-form-field
+                    [label]="'customer.title' | translate"
+                    for="title"
+                    [readOnlyToggle]="!(isNew$ | async)"
+                >
+                    <input id="title" type="text" formControlName="title" />
+                </vdr-form-field>
+                <div><!-- spacer --></div>
+                <vdr-form-field
+                    [label]="'customer.first-name' | translate"
+                    for="firstName"
+                    [readOnlyToggle]="!(isNew$ | async)"
+                >
+                    <input id="firstName" type="text" formControlName="firstName" />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'customer.last-name' | translate"
+                    for="lastName"
+                    [readOnlyToggle]="!(isNew$ | async)"
+                >
+                    <input id="lastName" type="text" formControlName="lastName" />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'customer.email-address' | translate"
+                    for="emailAddress"
+                    [readOnlyToggle]="!(isNew$ | async)"
+                >
+                    <input id="emailAddress" type="text" formControlName="emailAddress" />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'customer.phone-number' | translate"
+                    for="phoneNumber"
+                    [readOnlyToggle]="!(isNew$ | async)"
+                >
+                    <input id="phoneNumber" type="text" formControlName="phoneNumber" />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'customer.password' | translate"
+                    for="password"
+                    *ngIf="isNew$ | async"
+                >
+                    <input id="password" type="password" formControlName="password" />
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card
+                formGroupName="customFields"
+                *ngIf="customFields.length"
+                [title]="'common.custom-fields' | translate"
+            >
+                <vdr-tabbed-custom-fields
+                    entityName="Customer"
+                    [customFields]="customFields"
+                    [customFieldsFormGroup]="detailForm.get(['customer', 'customFields'])"
+                ></vdr-tabbed-custom-fields>
+            </vdr-card>
+        </form>
+        <vdr-custom-detail-component-host
+            locationId="customer-detail"
+            [entity$]="entity$"
+            [detailForm]="detailForm"
+        ></vdr-custom-detail-component-host>
+        <ng-container *ngIf="!(isNew$ | async)">
+            <vdr-card [title]="'customer.addresses' | translate">
+                <vdr-address-card
+                    *ngFor="let addressForm of getAddressFormControls()"
+                    [class.to-delete]="addressesToDeleteIds.has(addressForm.value.id)"
+                    [availableCountries]="availableCountries$ | async"
+                    [isDefaultBilling]="defaultBillingAddressId === addressForm.value.id"
+                    [isDefaultShipping]="defaultShippingAddressId === addressForm.value.id"
+                    [addressForm]="addressForm"
+                    [customFields]="addressCustomFields"
+                    [editable]="
+                        (['UpdateCustomer'] | hasPermission) &&
+                        !addressesToDeleteIds.has(addressForm.value.id)
+                    "
+                    (setAsDefaultBilling)="setDefaultBillingAddressId($event)"
+                    (setAsDefaultShipping)="setDefaultShippingAddressId($event)"
+                    (deleteAddress)="toggleDeleteAddress($event)"
+                ></vdr-address-card>
+                <div class="card-span">
+                    <button
+                        class="btn btn-secondary"
+                        (click)="addAddress()"
+                        *vdrIfPermissions="'UpdateCustomer'"
+                    >
+                        <clr-icon shape="plus"></clr-icon>
+                        {{ 'customer.create-new-address' | translate }}
+                    </button>
+                </div>
+            </vdr-card>
+            <vdr-card [title]="'customer.orders' | translate" [paddingX]="false">
+                <vdr-data-table-2
+                    class="card-span"
+                    id="customer-order-list"
+                    [items]="orders$ | async"
+                    [itemsPerPage]="ordersPerPage"
+                    [totalItems]="ordersCount$ | async"
+                    [currentPage]="currentOrdersPage"
+                    [emptyStateLabel]="'customer.no-orders-placed' | translate"
+                    (itemsPerPageChange)="setOrderItemsPerPage($event)"
+                    (pageChange)="setOrderCurrentPage($event)"
+                >
+                    <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true">
+                        <ng-template let-order="item">
+                            {{ order.id }}
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'common.created-at' | translate" [hiddenByDefault]="true">
+                        <ng-template let-order="item">
+                            {{ order.createdAt | localeDate : 'short' }}
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'common.code' | translate" [optional]="false">
+                        <ng-template let-order="item">
+                            <a class="button-ghost" [routerLink]="['./', order.id]"
+                                ><span>{{ order.code }}</span>
+                                <clr-icon shape="arrow right"></clr-icon>
+                            </a>
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'order.order-type' | translate" [hiddenByDefault]="true">
+                        <ng-template let-order="item">
+                            <vdr-chip>{{ order.type }}</vdr-chip>
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'order.state' | translate">
+                        <ng-template let-order="item">
+                            <vdr-order-state-label [state]="order.state"></vdr-order-state-label>
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'order.total' | translate">
+                        <ng-template let-order="item">
+                            {{ order.totalWithTax | localeCurrency : order.currencyCode }}
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'common.updated-at' | translate">
+                        <ng-template let-order="item">
+                            {{ order.updatedAt | timeAgo }}
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-column [heading]="'order.placed-at' | translate">
+                        <ng-template let-order="item">
+                            {{ order.orderPlacedAt | localeDate : 'short' }}
+                        </ng-template>
+                    </vdr-dt2-column>
+                    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+                </vdr-data-table-2>
+
+            </vdr-card>
+            <vdr-card [title]="'customer.customer-history' | translate">
+                <vdr-customer-history
+                    class="card-span"
+                    [customer]="entity$ | async"
+                    [history]="history$ | async"
+                    (addNote)="addNoteToCustomer($event)"
+                    (updateNote)="updateNote($event)"
+                    (deleteNote)="deleteNote($event)"
+                ></vdr-customer-history>
+            </vdr-card>
+        </ng-container>
+    </vdr-page-block>
+</vdr-page-detail-layout>

+ 123 - 84
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts

@@ -1,36 +1,32 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import {
-    UntypedFormArray,
-    UntypedFormBuilder,
-    UntypedFormControl,
-    UntypedFormGroup,
-    Validators,
-} from '@angular/forms';
+import { FormBuilder, UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    BaseDetailComponent,
     CreateAddressInput,
     CreateCustomerAddressMutation,
     CreateCustomerInput,
     Customer,
-    CustomFieldConfig,
+    CUSTOMER_FRAGMENT,
+    CustomerDetailQueryDocument,
+    CustomerDetailQueryQuery,
     DataService,
     DeleteCustomerAddressMutation,
     EditNoteDialogComponent,
     GetAvailableCountriesQuery,
     GetCustomerHistoryQuery,
-    GetCustomerQuery,
     ModalService,
     NotificationService,
     ServerConfigService,
     SortOrder,
     TimelineHistoryEntry,
+    TypedBaseDetailComponent,
     UpdateCustomerAddressMutation,
     UpdateCustomerInput,
     UpdateCustomerMutation,
 } from '@vendure/admin-ui/core';
-import { assertNever, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { gql } from 'apollo-angular';
 import { EMPTY, forkJoin, from, Observable, Subject } from 'rxjs';
 import {
     concatMap,
@@ -46,7 +42,35 @@ import {
 
 import { SelectCustomerGroupDialogComponent } from '../select-customer-group-dialog/select-customer-group-dialog.component';
 
-type CustomerWithOrders = NonNullable<GetCustomerQuery['customer']>;
+type CustomerWithOrders = NonNullable<CustomerDetailQueryQuery['customer']>;
+
+export const CUSTOMER_DETAIL_QUERY = gql`
+    query CustomerDetailQuery($id: ID!, $orderListOptions: OrderListOptions) {
+        customer(id: $id) {
+            ...Customer
+            groups {
+                id
+                name
+            }
+            orders(options: $orderListOptions) {
+                items {
+                    id
+                    code
+                    type
+                    state
+                    total
+                    totalWithTax
+                    currencyCode
+                    createdAt
+                    updatedAt
+                    orderPlacedAt
+                }
+                totalItems
+            }
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;
 
 @Component({
     selector: 'vdr-customer-detail',
@@ -55,14 +79,27 @@ type CustomerWithOrders = NonNullable<GetCustomerQuery['customer']>;
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class CustomerDetailComponent
-    extends BaseDetailComponent<CustomerWithOrders>
+    extends TypedBaseDetailComponent<typeof CustomerDetailQueryDocument, 'customer'>
     implements OnInit, OnDestroy
 {
-    detailForm: UntypedFormGroup;
-    customFields: CustomFieldConfig[];
-    addressCustomFields: CustomFieldConfig[];
+    customFields = this.getCustomFieldConfig('Customer');
+    addressCustomFields = this.getCustomFieldConfig('Address');
+    detailForm = this.formBuilder.group({
+        customer: this.formBuilder.group({
+            title: '',
+            firstName: ['', Validators.required],
+            lastName: ['', Validators.required],
+            phoneNumber: '',
+            emailAddress: ['', [Validators.required, Validators.email]],
+            password: '',
+            customFields: this.formBuilder.group(
+                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+            ),
+        }),
+        addresses: new UntypedFormArray([]),
+    });
     availableCountries$: Observable<GetAvailableCountriesQuery['countries']['items']>;
-    orders$: Observable<NonNullable<GetCustomerQuery['customer']>['orders']['items']>;
+    orders$: Observable<CustomerWithOrders['orders']['items']>;
     ordersCount$: Observable<number>;
     history$: Observable<NonNullable<GetCustomerHistoryQuery['customer']>['history']['items'] | undefined>;
     fetchHistory = new Subject<void>();
@@ -79,29 +116,12 @@ export class CustomerDetailComponent
         router: Router,
         serverConfigService: ServerConfigService,
         private changeDetector: ChangeDetectorRef,
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         protected dataService: DataService,
         private modalService: ModalService,
         private notificationService: NotificationService,
     ) {
         super(route, router, serverConfigService, dataService);
-
-        this.customFields = this.getCustomFieldConfig('Customer');
-        this.addressCustomFields = this.getCustomFieldConfig('Address');
-        this.detailForm = this.formBuilder.group({
-            customer: this.formBuilder.group({
-                title: '',
-                firstName: ['', Validators.required],
-                lastName: ['', Validators.required],
-                phoneNumber: '',
-                emailAddress: ['', [Validators.required, Validators.email]],
-                password: '',
-                customFields: this.formBuilder.group(
-                    this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-                ),
-            }),
-            addresses: new UntypedFormArray([]),
-        });
     }
 
     ngOnInit() {
@@ -170,14 +190,17 @@ export class CustomerDetailComponent
             phoneNumber: '',
             defaultShippingAddress: false,
             defaultBillingAddress: false,
+            customFields: this.formBuilder.group(
+                this.addressCustomFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+            ),
         });
-        if (this.addressCustomFields.length) {
-            const customFieldsGroup = this.formBuilder.group({});
-            for (const fieldDef of this.addressCustomFields) {
-                customFieldsGroup.addControl(fieldDef.name, new UntypedFormControl(''));
-            }
-            newAddress.addControl('customFields', customFieldsGroup);
-        }
+        // if (this.addressCustomFields.length) {
+        //     const customFieldsGroup = this.formBuilder.group({});
+        //     for (const fieldDef of this.addressCustomFields) {
+        //         customFieldsGroup.addControl(fieldDef.name, new UntypedFormControl(''));
+        //     }
+        //     newAddress.addControl('customFields', customFieldsGroup);
+        // }
         addressFormArray.push(newAddress);
     }
 
@@ -196,41 +219,42 @@ export class CustomerDetailComponent
         if (!customerForm) {
             return;
         }
-        const formValue = customerForm.value;
+        const { title, emailAddress, firstName, lastName, phoneNumber, password } = customerForm.value;
         const customFields = customerForm.get('customFields')?.value;
+        if (!emailAddress || !firstName || !lastName) {
+            return;
+        }
         const customer: CreateCustomerInput = {
-            title: formValue.title,
-            emailAddress: formValue.emailAddress,
-            firstName: formValue.firstName,
-            lastName: formValue.lastName,
-            phoneNumber: formValue.phoneNumber,
+            title,
+            emailAddress,
+            firstName,
+            lastName,
+            phoneNumber,
             customFields,
         };
-        this.dataService.customer
-            .createCustomer(customer, formValue.password)
-            .subscribe(({ createCustomer }) => {
-                switch (createCustomer.__typename) {
-                    case 'Customer':
-                        this.notificationService.success(_('common.notify-create-success'), {
-                            entity: 'Customer',
+        this.dataService.customer.createCustomer(customer, password).subscribe(({ createCustomer }) => {
+            switch (createCustomer.__typename) {
+                case 'Customer':
+                    this.notificationService.success(_('common.notify-create-success'), {
+                        entity: 'Customer',
+                    });
+                    if (createCustomer.emailAddress && !password) {
+                        this.notificationService.notify({
+                            message: _('customer.email-verification-sent'),
+                            translationVars: { emailAddress },
+                            type: 'info',
+                            duration: 10000,
                         });
-                        if (createCustomer.emailAddress && !formValue.password) {
-                            this.notificationService.notify({
-                                message: _('customer.email-verification-sent'),
-                                translationVars: { emailAddress: formValue.emailAddress },
-                                type: 'info',
-                                duration: 10000,
-                            });
-                        }
-                        this.detailForm.markAsPristine();
-                        this.addressDefaultsUpdated = false;
-                        this.changeDetector.markForCheck();
-                        this.router.navigate(['../', createCustomer.id], { relativeTo: this.route });
-                        break;
-                    case 'EmailAddressConflictError':
-                        this.notificationService.error(createCustomer.message);
-                }
-            });
+                    }
+                    this.detailForm.markAsPristine();
+                    this.addressDefaultsUpdated = false;
+                    this.changeDetector.markForCheck();
+                    this.router.navigate(['../', createCustomer.id], { relativeTo: this.route });
+                    break;
+                case 'EmailAddressConflictError':
+                    this.notificationService.error(createCustomer.message);
+            }
+        });
     }
 
     save() {
@@ -331,7 +355,7 @@ export class CustomerDetailComponent
                                     this.addressDefaultsUpdated = false;
                                     this.changeDetector.markForCheck();
                                     this.fetchHistory.next();
-                                    this.dataService.customer.getCustomer(this.id).single$.subscribe();
+                                    this.refreshCustomer().subscribe();
                                 }
                                 break;
                             case 'EmailAddressConflictError':
@@ -365,13 +389,13 @@ export class CustomerDetailComponent
                     });
                 },
                 complete: () => {
-                    this.dataService.customer.getCustomer(this.id, { take: 0 }).single$.subscribe();
+                    this.refreshCustomer().subscribe();
                     this.fetchHistory.next();
                 },
             });
     }
 
-    removeFromGroup(group: NonNullable<GetCustomerQuery['customer']>['groups'][number]) {
+    removeFromGroup(group: CustomerWithOrders['groups'][number]) {
         this.modalService
             .dialog({
                 title: _('customer.confirm-remove-customer-from-group'),
@@ -386,7 +410,7 @@ export class CustomerDetailComponent
                         ? this.dataService.customer.removeCustomersFromGroup(group.id, [this.id])
                         : EMPTY,
                 ),
-                switchMap(() => this.dataService.customer.getCustomer(this.id, { take: 0 }).single$),
+                switchMap(() => this.refreshCustomer()),
             )
             .subscribe(result => {
                 this.notificationService.success(_(`customer.remove-customers-from-group-success`), {
@@ -458,21 +482,26 @@ export class CustomerDetailComponent
         const customerGroup = this.detailForm.get('customer');
         if (customerGroup) {
             customerGroup.patchValue({
-                title: entity.title,
+                title: entity.title ?? null,
                 firstName: entity.firstName,
                 lastName: entity.lastName,
-                phoneNumber: entity.phoneNumber,
+                phoneNumber: entity.phoneNumber ?? null,
                 emailAddress: entity.emailAddress,
+                password: '',
+                customFields: {},
             });
         }
 
         if (entity.addresses) {
             const addressesArray = new UntypedFormArray([]);
             for (const address of entity.addresses) {
-                const { customFields, ...rest } = address as any;
+                const { customFields, ...rest } = address as typeof address & { customFields: any };
                 const addressGroup = this.formBuilder.group({
                     ...rest,
                     countryCode: address.country.code,
+                    customFields: this.formBuilder.group(
+                        this.addressCustomFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+                    ),
                 });
                 addressesArray.push(addressGroup);
                 if (address.defaultShippingAddress) {
@@ -510,11 +539,14 @@ export class CustomerDetailComponent
      * Refetch the customer with the current order list settings.
      */
     private fetchOrdersList() {
-        this.dataService.customer
-            .getCustomer(this.id, {
-                take: this.ordersPerPage,
-                skip: (this.currentOrdersPage - 1) * this.ordersPerPage,
-                sort: { orderPlacedAt: SortOrder.DESC },
+        this.dataService
+            .query(CustomerDetailQueryDocument, {
+                id: this.id,
+                orderListOptions: {
+                    take: this.ordersPerPage,
+                    skip: (this.currentOrdersPage - 1) * this.ordersPerPage,
+                    sort: { orderPlacedAt: SortOrder.DESC },
+                },
             })
             .single$.pipe(
                 map(data => data.customer),
@@ -522,4 +554,11 @@ export class CustomerDetailComponent
             )
             .subscribe(result => this.orderListUpdates$.next(result));
     }
+
+    private refreshCustomer() {
+        return this.dataService.query(CustomerDetailQueryDocument, {
+            id: this.id,
+            orderListOptions: { take: 0 },
+        }).single$;
+    }
 }

+ 62 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-detail/customer-group-detail.component.html

@@ -0,0 +1,62 @@
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left> </vdr-ab-left>
+
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="customer-group-detail"></vdr-action-bar-items>
+            <button
+                class="btn btn-primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="!(detailForm.valid && detailForm.dirty)"
+            >
+                {{ 'common.create' | translate }}
+            </button>
+            <ng-template #updateButton>
+                <button
+                    *vdrIfPermissions="'UpdateCustomer'"
+                    class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="!(detailForm.valid && detailForm.dirty)"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+<form class="form" [formGroup]="detailForm">
+    <vdr-page-detail-layout>
+        <vdr-page-detail-sidebar>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+        <vdr-page-block>
+            <vdr-card>
+                <vdr-form-field
+                    [label]="'common.name' | translate"
+                    for="name"
+                >
+                    <input id="name" type="text" formControlName="name" />
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card
+                formGroupName="customFields"
+                *ngIf="customFields.length"
+                [title]="'common.custom-fields' | translate"
+            >
+                <vdr-tabbed-custom-fields
+                    entityName="Customer"
+                    [customFields]="customFields"
+                    [customFieldsFormGroup]="detailForm.get(['customer', 'customFields'])"
+                ></vdr-tabbed-custom-fields>
+            </vdr-card>
+            <vdr-custom-detail-component-host
+                locationId="customer-group-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
+</form>

+ 0 - 0
packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.scss → packages/admin-ui/src/lib/customer/src/components/customer-group-detail/customer-group-detail.component.scss


+ 119 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-detail/customer-group-detail.component.ts

@@ -0,0 +1,119 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormBuilder, UntypedFormGroup } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { ResultOf } from '@graphql-typed-document-node/core';
+import {
+    DataService,
+    GetCustomerGroupDetailDocument,
+    ModalService,
+    NotificationService,
+    ServerConfigService,
+    TypedBaseDetailComponent,
+} from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
+
+export const CUSTOMER_GROUP_DETAIL_QUERY = gql`
+    query GetCustomerGroupDetail($id: ID!) {
+        customerGroup(id: $id) {
+            ...CustomerGroupDetail
+        }
+    }
+    fragment CustomerGroupDetail on CustomerGroup {
+        id
+        createdAt
+        updatedAt
+        name
+    }
+`;
+
+@Component({
+    selector: 'vdr-customer-group-detail',
+    templateUrl: './customer-group-detail.component.html',
+    styleUrls: ['./customer-group-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CustomerGroupDetailComponent
+    extends TypedBaseDetailComponent<typeof GetCustomerGroupDetailDocument, 'customerGroup'>
+    implements OnInit
+{
+    customFields = this.getCustomFieldConfig('CustomerGroup');
+    detailForm = this.formBuilder.group({
+        name: '',
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
+
+    constructor(
+        route: ActivatedRoute,
+        router: Router,
+        serverConfigService: ServerConfigService,
+        private formBuilder: FormBuilder,
+        protected dataService: DataService,
+        private modalService: ModalService,
+        private notificationService: NotificationService,
+    ) {
+        super(route, router, serverConfigService, dataService);
+    }
+
+    ngOnInit() {
+        super.init();
+    }
+
+    create() {
+        const formvalue = this.detailForm.value;
+        if (formvalue.name) {
+            this.dataService.customer
+                .createCustomerGroup({
+                    name: formvalue.name,
+                    customFields: formvalue.customFields,
+                    customerIds: [],
+                })
+                .subscribe(
+                    ({ createCustomerGroup }) => {
+                        this.notificationService.success(_('common.notify-create-success'), {
+                            entity: 'CustomerGroup',
+                        });
+                        this.detailForm.markAsPristine();
+                        this.router.navigate(['../', createCustomerGroup.id], { relativeTo: this.route });
+                    },
+                    err => {
+                        this.notificationService.error(_('common.notify-create-error'), {
+                            entity: 'CustomerGroup',
+                        });
+                    },
+                );
+        }
+    }
+
+    save() {
+        const formValue = this.detailForm.value;
+        this.dataService.customer.updateCustomerGroup({ id: this.id, ...formValue }).subscribe(
+            () => {
+                this.notificationService.success(_('common.notify-update-success'), {
+                    entity: 'CustomerGroup',
+                });
+                this.detailForm.markAsPristine();
+            },
+            err => {
+                this.notificationService.error(_('common.notify-update-error'), {
+                    entity: 'CustomerGroup',
+                });
+            },
+        );
+    }
+
+    protected setFormValues(
+        entity: NonNullable<ResultOf<typeof GetCustomerGroupDetailDocument>['customerGroup']>,
+    ) {
+        this.detailForm.patchValue({
+            name: entity.name,
+        });
+
+        if (this.customFields.length) {
+            const customFieldsGroup = this.detailForm.get(['customFields']) as UntypedFormGroup;
+            this.setCustomFieldFormValues(this.customFields, this.detailForm.get('customFields'), entity);
+        }
+    }
+}

+ 105 - 105
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html

@@ -1,106 +1,106 @@
-<vdr-page-header>
-    <vdr-page-title>
-        <vdr-action-bar-items locationId="customer-group-list"></vdr-action-bar-items>
-        <button class="btn btn-primary" *vdrIfPermissions="'CreateCustomerGroup'" (click)="create()">
-            <clr-icon shape="plus"></clr-icon>
-            {{ 'customer.create-new-customer-group' | translate }}
-        </button>
-    </vdr-page-title>
-</vdr-page-header>
-<vdr-page-body>
-    <vdr-split-view [rightPanelOpen]="activeGroup$ | async" (closeClicked)="closeMembers()">
-        <ng-template vdrSplitViewLeft>
-            <vdr-data-table-2
-                class="mt-2"
-                id="customer-group-list"
-                [items]="items$ | async"
-                [itemsPerPage]="itemsPerPage$ | async"
-                [totalItems]="totalItems$ | async"
-                [currentPage]="currentPage$ | async"
-                [filters]="filters"
-                [activeIndex]="activeIndex$ | async"
-                (pageChange)="setPageNumber($event)"
-                (itemsPerPageChange)="setItemsPerPage($event)"
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left> </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="customer-group-list"></vdr-action-bar-items>
+            <a class="btn btn-primary" *vdrIfPermissions="'CreateCustomerGroup'" [routerLink]="['./', 'create']">
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'customer.create-new-customer-group' | translate }}
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+<vdr-split-view [rightPanelOpen]="activeGroup$ | async" (closeClicked)="closeMembers()">
+    <ng-template vdrSplitViewLeft>
+        <vdr-data-table-2
+            class="mt-2"
+            id="customer-group-list"
+            [items]="items$ | async"
+            [itemsPerPage]="itemsPerPage$ | async"
+            [totalItems]="totalItems$ | async"
+            [currentPage]="currentPage$ | async"
+            [filters]="filters"
+            [activeIndex]="activeIndex$ | async"
+            (pageChange)="setPageNumber($event)"
+            (itemsPerPageChange)="setItemsPerPage($event)"
+        >
+            <vdr-bulk-action-menu
+                locationId="customer-group-list"
+                [hostComponent]="this"
+                [selectionManager]="selectionManager"
+            ></vdr-bulk-action-menu>
+            <vdr-dt2-search
+                [searchTermControl]="searchTermControl"
+                [searchTermPlaceholder]="'common.search-by-name' | translate"
+            ></vdr-dt2-search>
+            <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true">
+                <ng-template let-customerGroup="item">
+                    {{ customerGroup.id }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column
+                [heading]="'common.created-at' | translate"
+                [hiddenByDefault]="true"
+                [sort]="sorts.get('createdAt')"
             >
-                <vdr-bulk-action-menu
-                    locationId="customer-group-list"
-                    [hostComponent]="this"
-                    [selectionManager]="selectionManager"
-                ></vdr-bulk-action-menu>
-                <vdr-dt2-search
-                    [searchTermControl]="searchTermControl"
-                    [searchTermPlaceholder]="'common.search-by-name' | translate"
-                ></vdr-dt2-search>
-                <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true">
-                    <ng-template let-customerGroup="item">
-                        {{ customerGroup.id }}
-                    </ng-template>
-                </vdr-dt2-column>
-                <vdr-dt2-column
-                    [heading]="'common.created-at' | translate"
-                    [hiddenByDefault]="true"
-                    [sort]="sorts.get('createdAt')"
-                >
-                    <ng-template let-customerGroup="item">
-                        {{ customerGroup.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-customerGroup="item">
-                        {{ customerGroup.updatedAt | localeDate : 'short' }}
-                    </ng-template>
-                </vdr-dt2-column>
-                <vdr-dt2-column
-                    [heading]="'common.name' | translate"
-                    [optional]="false"
-                    [sort]="sorts.get('name')"
-                >
-                    <ng-template let-customerGroup="item">
-                        <a class="button-ghost" [routerLink]="['./', customerGroup.id]"
-                            ><span>{{ customerGroup.name }}</span>
-                            <clr-icon shape="arrow right"></clr-icon>
-                        </a>
-                    </ng-template>
-                </vdr-dt2-column>
-                <vdr-dt2-column
-                    [heading]="'common.view-contents' | translate"
-                    [optional]="false"
-                    [sort]="sorts.get('name')"
-                >
-                    <ng-template let-customerGroup="item">
-                        <a
-                            class="button-small bg-weight-150"
-                            [routerLink]="['./', { contents: customerGroup.id }]"
-                            queryParamsHandling="preserve"
-                        >
-                            <span>{{ 'customer.view-group-members' | translate }}</span>
-                            <clr-icon shape="file-group"></clr-icon>
-                        </a>
-                    </ng-template>
-                </vdr-dt2-column>
-            </vdr-data-table-2>
-        </ng-template>
-        <ng-template vdrSplitViewRight [splitViewTitle]="(activeGroup$ | async)?.name">
-            <ng-container *ngIf="activeGroup$ | async as activeGroup">
-                <button class="button-ghost ml-4" (click)="addToGroup(activeGroup)">
-                    <clr-icon shape="plus"></clr-icon>
-                    <span>{{
-                        'customer.add-customers-to-group' | translate : { groupName: activeGroup.name }
-                    }}</span>
-                </button>
-                <vdr-customer-group-member-list
-                    locationId="customer-group-members-list"
-                    [members]="members$ | async"
-                    [route]="route"
-                    [totalItems]="membersTotal$ | async"
-                    [activeGroup]="activeGroup$ | async"
-                    (fetchParamsChange)="fetchGroupMembers$.next($event)"
-                />
-            </ng-container>
-        </ng-template>
-    </vdr-split-view>
-</vdr-page-body>
+                <ng-template let-customerGroup="item">
+                    {{ customerGroup.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-customerGroup="item">
+                    {{ customerGroup.updatedAt | localeDate : 'short' }}
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column
+                [heading]="'common.name' | translate"
+                [optional]="false"
+                [sort]="sorts.get('name')"
+            >
+                <ng-template let-customerGroup="item">
+                    <a class="button-ghost" [routerLink]="['./', customerGroup.id]"
+                        ><span>{{ customerGroup.name }}</span>
+                        <clr-icon shape="arrow right"></clr-icon>
+                    </a>
+                </ng-template>
+            </vdr-dt2-column>
+            <vdr-dt2-column
+                [heading]="'common.view-contents' | translate"
+                [optional]="false"
+            >
+                <ng-template let-customerGroup="item">
+                    <a
+                        class="button-small bg-weight-150"
+                        [routerLink]="['./', { contents: customerGroup.id }]"
+                        queryParamsHandling="preserve"
+                    >
+                        <span>{{ 'customer.view-group-members' | translate }}</span>
+                        <clr-icon shape="file-group"></clr-icon>
+                    </a>
+                </ng-template>
+            </vdr-dt2-column>
+        </vdr-data-table-2>
+    </ng-template>
+    <ng-template vdrSplitViewRight [splitViewTitle]="(activeGroup$ | async)?.name">
+        <ng-container *ngIf="activeGroup$ | async as activeGroup">
+            <button class="button-ghost ml-4" (click)="addToGroup(activeGroup)">
+                <clr-icon shape="plus"></clr-icon>
+                <span>{{
+                    'customer.add-customers-to-group' | translate : { groupName: activeGroup.name }
+                }}</span>
+            </button>
+            <vdr-customer-group-member-list
+                locationId="customer-group-members-list"
+                [members]="members$ | async"
+                [route]="route"
+                [totalItems]="membersTotal$ | async"
+                [activeGroup]="activeGroup$ | async"
+                (fetchParamsChange)="fetchGroupMembers$.next($event)"
+            />
+        </ng-container>
+    </ng-template>
+</vdr-split-view>

+ 34 - 75
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.ts

@@ -2,24 +2,35 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    BaseListComponent,
-    CustomerGroupFilterParameter,
-    CustomerGroupSortParameter,
+    CUSTOMER_GROUP_FRAGMENT,
     DataService,
-    DataTableService,
+    GetCustomerGroupListDocument,
     GetCustomerGroupsQuery,
     GetCustomerGroupWithCustomersQuery,
     ItemOf,
     ModalService,
     NotificationService,
+    TypedBaseListComponent,
 } from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
 import { BehaviorSubject, combineLatest, EMPTY, Observable, of } from 'rxjs';
-import { distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators';
+import { distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators';
 
 import { AddCustomerToGroupDialogComponent } from '../add-customer-to-group-dialog/add-customer-to-group-dialog.component';
-import { CustomerGroupDetailDialogComponent } from '../customer-group-detail-dialog/customer-group-detail-dialog.component';
 import { CustomerGroupMemberFetchParams } from '../customer-group-member-list/customer-group-member-list.component';
 
+export const GET_CUSTOMER_GROUP_LIST = gql`
+    query GetCustomerGroupList($options: CustomerGroupListOptions) {
+        customerGroups(options: $options) {
+            items {
+                ...CustomerGroup
+            }
+            totalItems
+        }
+    }
+    ${CUSTOMER_GROUP_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-customer-group-list',
     templateUrl: './customer-group-list.component.html',
@@ -27,10 +38,7 @@ import { CustomerGroupMemberFetchParams } from '../customer-group-member-list/cu
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class CustomerGroupListComponent
-    extends BaseListComponent<
-        GetCustomerGroupsQuery,
-        GetCustomerGroupsQuery['customerGroups']['items'][number]
-    >
+    extends TypedBaseListComponent<typeof GetCustomerGroupListDocument, 'customerGroups'>
     implements OnInit
 {
     activeGroup$: Observable<ItemOf<GetCustomerGroupsQuery, 'customerGroups'> | undefined>;
@@ -45,13 +53,17 @@ export class CustomerGroupListComponent
         take: 0,
         filterTerm: '',
     });
-    readonly filters = this.dataTableService
-        .createFilterCollection<CustomerGroupFilterParameter>()
+    readonly filters = this.createFilterCollection()
         .addDateFilters()
+        .addFilter({
+            name: 'name',
+            type: { kind: 'text' },
+            label: _('common.name'),
+            filterField: 'name',
+        })
         .connectToRoute(this.route);
 
-    readonly sorts = this.dataTableService
-        .createSortCollection<CustomerGroupSortParameter>()
+    readonly sorts = this.createSortCollection()
         .defaultSort('createdAt', 'DESC')
         .addSort({ name: 'createdAt' })
         .addSort({ name: 'updatedAt' })
@@ -60,19 +72,17 @@ export class CustomerGroupListComponent
     private refreshActiveGroupMembers$ = new BehaviorSubject<void>(undefined);
 
     constructor(
-        private dataService: DataService,
+        protected dataService: DataService,
         private notificationService: NotificationService,
         private modalService: ModalService,
         public route: ActivatedRoute,
         protected router: Router,
-        private dataTableService: DataTableService,
     ) {
-        super(router, route);
-        super.setQueryFn(
-            (...args: any[]) =>
-                this.dataService.customer.getCustomerGroupList(...args).refetchOnChannelChange(),
-            data => data.customerGroups,
-            (skip, take) => ({
+        super();
+        super.configure({
+            document: GetCustomerGroupListDocument,
+            getItems: data => data.customerGroups,
+            setVariables: (skip, take) => ({
                 options: {
                     skip,
                     take,
@@ -83,7 +93,8 @@ export class CustomerGroupListComponent
                     sort: this.sorts.createSortInput(),
                 },
             }),
-        );
+            refreshListOnChanges: [this.filters.valueChanges, this.sorts.valueChanges],
+        });
     }
 
     ngOnInit(): void {
@@ -135,58 +146,6 @@ export class CustomerGroupListComponent
 
         this.members$ = membersResult$.pipe(map(res => res?.items ?? []));
         this.membersTotal$ = membersResult$.pipe(map(res => res?.totalItems ?? 0));
-
-        super.refreshListOnChanges(this.filters.valueChanges, this.sorts.valueChanges);
-    }
-
-    create() {
-        this.modalService
-            .fromComponent(CustomerGroupDetailDialogComponent, { locals: { group: { name: '' } } })
-            .pipe(
-                switchMap(result =>
-                    result
-                        ? this.dataService.customer.createCustomerGroup({ ...result, customerIds: [] })
-                        : EMPTY,
-                ),
-            )
-            .subscribe(
-                () => {
-                    this.refresh();
-                    this.notificationService.success(_('common.notify-create-success'), {
-                        entity: 'CustomerGroup',
-                    });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-create-error'), {
-                        entity: 'CustomerGroup',
-                    });
-                },
-            );
-    }
-
-    update(group: ItemOf<GetCustomerGroupsQuery, 'customerGroups'>) {
-        this.modalService
-            .fromComponent(CustomerGroupDetailDialogComponent, { locals: { group } })
-            .pipe(
-                switchMap(result =>
-                    result
-                        ? this.dataService.customer.updateCustomerGroup({ id: group.id, ...result })
-                        : EMPTY,
-                ),
-            )
-            .subscribe(
-                () => {
-                    this.refresh();
-                    this.notificationService.success(_('common.notify-update-success'), {
-                        entity: 'CustomerGroup',
-                    });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-update-error'), {
-                        entity: 'CustomerGroup',
-                    });
-                },
-            );
     }
 
     closeMembers() {

+ 0 - 1
packages/admin-ui/src/lib/customer/src/components/customer-history/customer-history.component.html

@@ -1,4 +1,3 @@
-<h4>{{ 'customer.customer-history' | translate }}</h4>
 <div class="entry-list">
     <vdr-timeline-entry iconShape="note" displayType="muted" *vdrIfPermissions="'UpdateCustomer'">
         <div class="note-entry">

+ 73 - 79
packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.html

@@ -1,81 +1,75 @@
-<vdr-page-header>
-    <vdr-page-title>
-        <vdr-action-bar-items locationId="customer-list"></vdr-action-bar-items>
-        <a class="btn btn-primary" [routerLink]="['./create']" *vdrIfPermissions="'CreateCustomer'">
-            <clr-icon shape="plus"></clr-icon>
-            {{ 'customer.create-new-customer' | translate }}
-        </a>
-    </vdr-page-title>
-</vdr-page-header>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left> </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="customer-list"></vdr-action-bar-items>
+            <a class="btn btn-primary" [routerLink]="['./create']" *vdrIfPermissions="'CreateCustomer'">
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'customer.create-new-customer' | translate }}
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
 
-<vdr-page-body>
-    <vdr-data-table-2
-        class="mt-2"
-        id="customer-list"
-        [items]="items$ | async"
-        [itemsPerPage]="itemsPerPage$ | async"
-        [totalItems]="totalItems$ | async"
-        [currentPage]="currentPage$ | async"
-        [filters]="filters"
-        (pageChange)="setPageNumber($event)"
-        (itemsPerPageChange)="setItemsPerPage($event)"
+<vdr-data-table-2
+    class="mt-2"
+    id="customer-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="customer-list"
+        [hostComponent]="this"
+        [selectionManager]="selectionManager"
+    ></vdr-bulk-action-menu>
+    <vdr-dt2-search
+        [searchTermControl]="searchTermControl"
+        [searchTermPlaceholder]="'customer.search-customers-by-email-last-name-postal-code' | translate"
+    ></vdr-dt2-search>
+    <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true">
+        <ng-template let-customer="item">
+            {{ customer.id }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'common.created-at' | translate"
+        [hiddenByDefault]="true"
+        [sort]="sorts.get('createdAt')"
     >
-        <vdr-bulk-action-menu
-            locationId="customer-list"
-            [hostComponent]="this"
-            [selectionManager]="selectionManager"
-        ></vdr-bulk-action-menu>
-        <vdr-dt2-search
-            [searchTermControl]="searchTermControl"
-            [searchTermPlaceholder]="'customer.search-customers-by-email-last-name-postal-code' | translate"
-        ></vdr-dt2-search>
-        <vdr-dt2-column
-            [heading]="'common.id' | translate"
-            [hiddenByDefault]="true"
-        >
-            <ng-template let-customer="item">
-                {{ customer.id }}
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column
-            [heading]="'common.created-at' | translate"
-            [hiddenByDefault]="true"
-            [sort]="sorts.get('createdAt')"
-        >
-            <ng-template let-customer="item">
-                {{ customer.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-customer="item">
-                {{ customer.updatedAt | localeDate : 'short' }}
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column
-            [heading]="'customer.name' | translate"
-            [optional]="false"
-            [sort]="sorts.get('lastName')"
-        >
-            <ng-template let-customer="item">
-                <a class="button-ghost" [routerLink]="['./', customer.id]"
-                    ><span> {{ customer.title }} {{ customer.firstName }} {{ customer.lastName }} </span>
-                    <clr-icon shape="arrow right"></clr-icon>
-                </a>
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'common.status' | translate">
-            <ng-template let-customer="item">
-                <vdr-customer-status-label [customer]="customer" />
-            </ng-template>
-        </vdr-dt2-column>
-        <vdr-dt2-column [heading]="'customer.email-address' | translate" [sort]="sorts.get('emailAddress')">
-            <ng-template let-customer="item">
-                {{ customer.emailAddress }}
-            </ng-template>
-        </vdr-dt2-column>
-    </vdr-data-table-2>
-</vdr-page-body>
+        <ng-template let-customer="item">
+            {{ customer.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-customer="item">
+            {{ customer.updatedAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'customer.name' | translate" [optional]="false" [sort]="sorts.get('lastName')">
+        <ng-template let-customer="item">
+            <a class="button-ghost" [routerLink]="['./', customer.id]"
+                ><span> {{ customer.title }} {{ customer.firstName }} {{ customer.lastName }} </span>
+                <clr-icon shape="arrow right"></clr-icon>
+            </a>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'common.status' | translate">
+        <ng-template let-customer="item">
+            <vdr-customer-status-label [customer]="customer" />
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column [heading]="'customer.email-address' | translate" [sort]="sorts.get('emailAddress')">
+        <ng-template let-customer="item">
+            {{ customer.emailAddress }}
+        </ng-template>
+    </vdr-dt2-column>
+</vdr-data-table-2>

+ 38 - 33
packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.ts

@@ -1,16 +1,32 @@
 import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import {
-    BaseListComponent,
-    CustomerFilterParameter,
-    CustomerSortParameter,
-    DataService,
-    DataTableService,
-    GetCustomerListQuery,
-    ItemOf,
-    LogicalOperator,
-} from '@vendure/admin-ui/core';
+import { CustomerListQueryDocument, LogicalOperator, TypedBaseListComponent } from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
+
+export const CUSTOMER_LIST_QUERY = gql`
+    query CustomerListQuery($options: CustomerListOptions) {
+        customers(options: $options) {
+            items {
+                ...CustomerListItem
+            }
+            totalItems
+        }
+    }
+
+    fragment CustomerListItem on Customer {
+        id
+        createdAt
+        updatedAt
+        title
+        firstName
+        lastName
+        emailAddress
+        user {
+            id
+            verified
+        }
+    }
+`;
 
 @Component({
     selector: 'vdr-customer-list',
@@ -18,11 +34,10 @@ import {
     styleUrls: ['./customer-list.component.scss'],
 })
 export class CustomerListComponent
-    extends BaseListComponent<GetCustomerListQuery, ItemOf<GetCustomerListQuery, 'customers'>>
+    extends TypedBaseListComponent<typeof CustomerListQueryDocument, 'customers'>
     implements OnInit
 {
-    readonly filters = this.dataTableService
-        .createFilterCollection<CustomerFilterParameter>()
+    readonly filters = this.createFilterCollection()
         .addDateFilters()
         .addFilter({
             name: 'firstName',
@@ -44,8 +59,7 @@ export class CustomerListComponent
         })
         .connectToRoute(this.route);
 
-    readonly sorts = this.dataTableService
-        .createSortCollection<CustomerSortParameter>()
+    readonly sorts = this.createSortCollection()
         .defaultSort('createdAt', 'DESC')
         .addSort({ name: 'createdAt' })
         .addSort({ name: 'updatedAt' })
@@ -53,17 +67,12 @@ export class CustomerListComponent
         .addSort({ name: 'emailAddress' })
         .connectToRoute(this.route);
 
-    constructor(
-        router: Router,
-        route: ActivatedRoute,
-        private dataService: DataService,
-        private dataTableService: DataTableService,
-    ) {
-        super(router, route);
-        super.setQueryFn(
-            (...args: any[]) => this.dataService.customer.getCustomerList(...args).refetchOnChannelChange(),
-            data => data.customers,
-            (skip, take) => ({
+    constructor() {
+        super();
+        this.configure({
+            document: CustomerListQueryDocument,
+            getItems: data => data.customers,
+            setVariables: (skip, take) => ({
                 options: {
                     skip,
                     take,
@@ -83,11 +92,7 @@ export class CustomerListComponent
                     sort: this.sorts.createSortInput(),
                 },
             }),
-        );
-    }
-
-    ngOnInit() {
-        super.ngOnInit();
-        super.refreshListOnChanges(this.filters.valueChanges, this.sorts.valueChanges);
+            refreshListOnChanges: [this.sorts.valueChanges, this.filters.valueChanges],
+        });
     }
 }

+ 73 - 5
packages/admin-ui/src/lib/customer/src/customer.module.ts

@@ -1,6 +1,14 @@
 import { NgModule } from '@angular/core';
-import { RouterModule } from '@angular/router';
-import { BulkActionRegistryService, SharedModule } from '@vendure/admin-ui/core';
+import { RouterModule, ROUTES } from '@angular/router';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import {
+    BulkActionRegistryService,
+    CustomerDetailQueryDocument,
+    detailComponentWithResolver,
+    GetCustomerGroupDetailDocument,
+    PageService,
+    SharedModule,
+} from '@vendure/admin-ui/core';
 
 import { AddCustomerToGroupDialogComponent } from './components/add-customer-to-group-dialog/add-customer-to-group-dialog.component';
 import { AddressCardComponent } from './components/address-card/address-card.component';
@@ -17,10 +25,19 @@ import { deleteCustomersBulkAction } from './components/customer-list/customer-l
 import { CustomerListComponent } from './components/customer-list/customer-list.component';
 import { CustomerStatusLabelComponent } from './components/customer-status-label/customer-status-label.component';
 import { SelectCustomerGroupDialogComponent } from './components/select-customer-group-dialog/select-customer-group-dialog.component';
-import { customerRoutes } from './customer.routes';
+import { createRoutes } from './customer.routes';
+import { CustomerGroupDetailComponent } from './components/customer-group-detail/customer-group-detail.component';
 
 @NgModule({
-    imports: [SharedModule, RouterModule.forChild(customerRoutes)],
+    imports: [SharedModule, RouterModule.forChild([])],
+    providers: [
+        {
+            provide: ROUTES,
+            useFactory: (pageService: PageService) => createRoutes(pageService),
+            multi: true,
+            deps: [PageService],
+        },
+    ],
     declarations: [
         CustomerListComponent,
         CustomerDetailComponent,
@@ -34,13 +51,64 @@ import { customerRoutes } from './customer.routes';
         CustomerHistoryComponent,
         AddressDetailDialogComponent,
         CustomerHistoryEntryHostComponent,
+        CustomerGroupDetailComponent,
     ],
     exports: [AddressCardComponent],
 })
 export class CustomerModule {
-    constructor(private bulkActionRegistryService: BulkActionRegistryService) {
+    constructor(
+        private bulkActionRegistryService: BulkActionRegistryService,
+        private pageService: PageService,
+    ) {
         bulkActionRegistryService.registerBulkAction(deleteCustomersBulkAction);
         bulkActionRegistryService.registerBulkAction(deleteCustomerGroupsBulkAction);
         bulkActionRegistryService.registerBulkAction(removeCustomerGroupMembersBulkAction);
+
+        pageService.registerPageTab({
+            location: 'customer-list',
+            tab: _('customer.customers'),
+            route: '',
+            component: CustomerListComponent,
+        });
+        pageService.registerPageTab({
+            location: 'customer-detail',
+            tab: _('customer.customer'),
+            route: '',
+            component: detailComponentWithResolver({
+                component: CustomerDetailComponent,
+                query: CustomerDetailQueryDocument,
+                entityKey: 'customer',
+                getBreadcrumbs: entity => [
+                    {
+                        label: entity
+                            ? `${entity?.firstName} ${entity?.lastName}`
+                            : _('customer.create-new-customer'),
+                        link: [entity?.id],
+                    },
+                ],
+            }),
+        });
+        pageService.registerPageTab({
+            location: 'customer-group-list',
+            tab: _('customer.customer-groups'),
+            route: '',
+            component: CustomerGroupListComponent,
+        });
+        pageService.registerPageTab({
+            location: 'customer-group-detail',
+            tab: _('customer.customer-group'),
+            route: '',
+            component: detailComponentWithResolver({
+                component: CustomerGroupDetailComponent,
+                query: GetCustomerGroupDetailDocument,
+                entityKey: 'customerGroup',
+                getBreadcrumbs: entity => [
+                    {
+                        label: entity ? entity.name : _('customer.create-new-customer-group'),
+                        link: [entity?.id],
+                    },
+                ],
+            }),
+        });
     }
 }

+ 21 - 8
packages/admin-ui/src/lib/customer/src/customer.routes.ts

@@ -5,36 +5,49 @@ import {
     createResolveData,
     CustomerFragment,
     detailBreadcrumb,
+    PageComponent,
+    PageService,
 } from '@vendure/admin-ui/core';
 
 import { CustomerDetailComponent } from './components/customer-detail/customer-detail.component';
 import { CustomerGroupListComponent } from './components/customer-group-list/customer-group-list.component';
 import { CustomerListComponent } from './components/customer-list/customer-list.component';
-import { CustomerResolver } from './providers/routing/customer-resolver';
 
-export const customerRoutes: Route[] = [
+export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: 'customers',
-        component: CustomerListComponent,
+        component: PageComponent,
         data: {
             breadcrumb: _('breadcrumb.customers'),
         },
+        children: pageService.getPageTabRoutes('customer-list'),
     },
     {
         path: 'customers/:id',
-        component: CustomerDetailComponent,
-        resolve: createResolveData(CustomerResolver),
-        canDeactivate: [CanDeactivateDetailGuard],
+        component: PageComponent,
         data: {
-            breadcrumb: customerBreadcrumb,
+            locationId: 'customer-detail',
+            breadcrumb: _('breadcrumb.customers'),
         },
+        children: pageService.getPageTabRoutes('customer-detail'),
     },
     {
         path: 'groups',
-        component: CustomerGroupListComponent,
+        component: PageComponent,
         data: {
+            locationId: 'customer-detail',
             breadcrumb: _('breadcrumb.customer-groups'),
         },
+        children: pageService.getPageTabRoutes('customer-group-list'),
+    },
+    {
+        path: 'groups/:id',
+        component: PageComponent,
+        data: {
+            locationId: 'customer-group-detail',
+            breadcrumb: { label: _('breadcrumb.customer-groups'), link: ['../', 'groups'] },
+        },
+        children: pageService.getPageTabRoutes('customer-group-detail'),
     },
 ];
 

+ 0 - 31
packages/admin-ui/src/lib/customer/src/providers/routing/customer-resolver.ts

@@ -1,31 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import { BaseEntityResolver, CustomerFragment, DataService, SortOrder } from '@vendure/admin-ui/core';
-
-@Injectable({
-    providedIn: 'root',
-})
-export class CustomerResolver extends BaseEntityResolver<CustomerFragment> {
-    constructor(router: Router, dataService: DataService) {
-        super(
-            router,
-            {
-                __typename: 'Customer',
-                id: '',
-                createdAt: '',
-                updatedAt: '',
-                title: '',
-                firstName: '',
-                lastName: '',
-                emailAddress: '',
-                phoneNumber: null,
-                addresses: null,
-                user: null,
-            },
-            id =>
-                dataService.customer
-                    .getCustomer(id, { take: 10, sort: { orderPlacedAt: SortOrder.DESC } })
-                    .mapStream(data => data.customer),
-        );
-    }
-}

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

@@ -16,4 +16,3 @@ export * from './components/customer-status-label/customer-status-label.componen
 export * from './components/select-customer-group-dialog/select-customer-group-dialog.component';
 export * from './customer.module';
 export * from './customer.routes';
-export * from './providers/routing/customer-resolver';

+ 165 - 148
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html

@@ -1,161 +1,178 @@
-<vdr-action-bar>
-    <vdr-ab-left>
-        <div class="flex clr-align-items-center">
-            <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-            <clr-toggle-wrapper *vdrIfPermissions="'UpdatePromotion'">
-                <input type="checkbox" clrToggle name="enabled" [formControl]="detailForm.get(['enabled'])" />
-                <label>{{ 'common.enabled' | translate }}</label>
-            </clr-toggle-wrapper>
-            <vdr-language-selector
-                [disabled]="isNew$ | async"
-                [availableLanguageCodes]="availableLanguages$ | async"
-                [currentLanguageCode]="languageCode$ | async"
-                (languageCodeChange)="setLanguage($event)"
-            ></vdr-language-selector>
-        </div>
-    </vdr-ab-left>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <div class="flex clr-align-items-center">
+                <vdr-language-selector
+                    [disabled]="isNew$ | async"
+                    [availableLanguageCodes]="availableLanguages$ | async"
+                    [currentLanguageCode]="languageCode$ | async"
+                    (languageCodeChange)="setLanguage($event)"
+                ></vdr-language-selector>
+            </div>
+        </vdr-ab-left>
 
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="promotion-detail"></vdr-action-bar-items>
-        <button
-            class="btn btn-primary"
-            *ngIf="isNew$ | async; else updateButton"
-            (click)="create()"
-            [disabled]="!saveButtonEnabled()"
-        >
-            {{ 'common.create' | translate }}
-        </button>
-        <ng-template #updateButton>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="promotion-detail"></vdr-action-bar-items>
             <button
                 class="btn btn-primary"
-                (click)="save()"
-                *vdrIfPermissions="'UpdatePromotion'"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
                 [disabled]="!saveButtonEnabled()"
             >
-                {{ 'common.update' | translate }}
+                {{ 'common.create' | translate }}
             </button>
-        </ng-template>
-    </vdr-ab-right>
-</vdr-action-bar>
+            <ng-template #updateButton>
+                <button
+                    class="btn btn-primary"
+                    (click)="save()"
+                    *vdrIfPermissions="'UpdatePromotion'"
+                    [disabled]="!saveButtonEnabled()"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
 
 <form class="form" [formGroup]="detailForm">
-    <vdr-form-field [label]="'common.name' | translate" for="name">
-        <input
-            id="name"
-            [readonly]="!('UpdatePromotion' | hasPermission)"
-            type="text"
-            formControlName="name"
-        />
-    </vdr-form-field>
-    <vdr-rich-text-editor
-        formControlName="description"
-        [readonly]="!('UpdatePromotion' | hasPermission)"
-        [label]="'common.description' | translate"
-    ></vdr-rich-text-editor>
-    <vdr-form-field [label]="'marketing.starts-at' | translate" for="startsAt">
-        <vdr-datetime-picker formControlName="startsAt"></vdr-datetime-picker>
-    </vdr-form-field>
-    <vdr-form-field [label]="'marketing.ends-at' | translate" for="endsAt">
-        <vdr-datetime-picker formControlName="endsAt"></vdr-datetime-picker>
-    </vdr-form-field>
-    <vdr-form-field [label]="'marketing.coupon-code' | translate" for="couponCode">
-        <input
-            id="couponCode"
-            [readonly]="!('UpdatePromotion' | hasPermission)"
-            type="text"
-            formControlName="couponCode"
-        />
-    </vdr-form-field>
-    <vdr-form-field [label]="'marketing.per-customer-limit' | translate" for="perCustomerUsageLimit">
-        <input
-            id="perCustomerUsageLimit"
-            [readonly]="!('UpdatePromotion' | hasPermission)"
-            type="number"
-            min="1"
-            max="999"
-            formControlName="perCustomerUsageLimit"
-        />
-    </vdr-form-field>
-    <section formGroupName="customFields" *ngIf="customFields.length">
-        <label>{{ 'common.custom-fields' | translate }}</label>
-        <vdr-tabbed-custom-fields
-            entityName="Promotion"
-            [customFields]="customFields"
-            [customFieldsFormGroup]="detailForm.get('customFields')"
-            [readonly]="!('UpdatePromotion' | hasPermission)"
-        ></vdr-tabbed-custom-fields>
-    </section>
-
-    <vdr-custom-detail-component-host
-        locationId="promotion-detail"
-        [entity$]="entity$"
-        [detailForm]="detailForm"
-    ></vdr-custom-detail-component-host>
-
-    <div class="clr-row">
-        <div class="clr-col" formArrayName="conditions">
-            <label class="clr-control-label">{{ 'marketing.conditions' | translate }}</label>
-            <ng-container *ngFor="let condition of conditions; index as i">
-                <vdr-configurable-input
-                    (remove)="removeCondition($event)"
-                    [position]="i"
+    <vdr-page-detail-layout>
+        <vdr-page-detail-sidebar>
+            <vdr-card *vdrIfPermissions="'UpdatePromotion'">
+                <clr-toggle-wrapper>
+                    <input type="checkbox" clrToggle name="enabled" formControlName="enabled" />
+                    <label>{{ 'common.enabled' | translate }}</label>
+                </clr-toggle-wrapper>
+            </vdr-card>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+        <vdr-page-block>
+            <vdr-card>
+                <vdr-form-field [label]="'common.name' | translate" for="name">
+                    <input
+                        id="name"
+                        [readonly]="!('UpdatePromotion' | hasPermission)"
+                        type="text"
+                        formControlName="name"
+                    />
+                </vdr-form-field>
+                <vdr-rich-text-editor
+                    class="card-span"
+                    formControlName="description"
+                    [readonly]="!('UpdatePromotion' | hasPermission)"
+                    [label]="'common.description' | translate"
+                ></vdr-rich-text-editor>
+                <vdr-form-field [label]="'marketing.starts-at' | translate" for="startsAt">
+                    <vdr-datetime-picker formControlName="startsAt"></vdr-datetime-picker>
+                </vdr-form-field>
+                <vdr-form-field [label]="'marketing.ends-at' | translate" for="endsAt">
+                    <vdr-datetime-picker formControlName="endsAt"></vdr-datetime-picker>
+                </vdr-form-field>
+                <vdr-form-field [label]="'marketing.coupon-code' | translate" for="couponCode">
+                    <input
+                        id="couponCode"
+                        [readonly]="!('UpdatePromotion' | hasPermission)"
+                        type="text"
+                        formControlName="couponCode"
+                    />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'marketing.per-customer-limit' | translate"
+                    for="perCustomerUsageLimit"
+                >
+                    <input
+                        id="perCustomerUsageLimit"
+                        [readonly]="!('UpdatePromotion' | hasPermission)"
+                        type="number"
+                        min="1"
+                        max="999"
+                        formControlName="perCustomerUsageLimit"
+                    />
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card
+                [title]="'common.custom-fields' | translate"
+                formGroupName="customFields"
+                *ngIf="customFields.length"
+            >
+                <vdr-tabbed-custom-fields
+                    entityName="Promotion"
+                    [customFields]="customFields"
+                    [customFieldsFormGroup]="detailForm.get('customFields')"
                     [readonly]="!('UpdatePromotion' | hasPermission)"
-                    [operation]="condition"
-                    [operationDefinition]="getConditionDefinition(condition)"
-                    [formControlName]="i"
-                ></vdr-configurable-input>
-            </ng-container>
+                ></vdr-tabbed-custom-fields>
+            </vdr-card>
+
+            <vdr-custom-detail-component-host
+                locationId="promotion-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
 
-            <div>
-                <vdr-dropdown *vdrIfPermissions="'UpdatePromotion'">
-                    <button class="btn btn-outline" vdrDropdownTrigger>
-                        <clr-icon shape="plus"></clr-icon>
-                        {{ 'marketing.add-condition' | translate }}
-                    </button>
-                    <vdr-dropdown-menu vdrPosition="bottom-left">
-                        <button
-                            *ngFor="let condition of getAvailableConditions()"
-                            type="button"
-                            vdrDropdownItem
-                            class="item-wrap"
-                            (click)="addCondition(condition)"
-                        >
-                            {{ condition.description }}
+            <vdr-card [title]="'marketing.conditions' | translate" formArrayName="conditions">
+                <div class="card-span" *ngFor="let condition of conditions; index as i">
+                    <vdr-configurable-input
+                        (remove)="removeCondition($event)"
+                        [position]="i"
+                        [readonly]="!('UpdatePromotion' | hasPermission)"
+                        [operation]="condition"
+                        [operationDefinition]="getConditionDefinition(condition)"
+                        [formControlName]="i"
+                    ></vdr-configurable-input>
+                </div>
+                <div>
+                    <vdr-dropdown *vdrIfPermissions="'UpdatePromotion'">
+                        <button class="btn btn-outline" vdrDropdownTrigger>
+                            <clr-icon shape="plus"></clr-icon>
+                            {{ 'marketing.add-condition' | translate }}
                         </button>
-                    </vdr-dropdown-menu>
-                </vdr-dropdown>
-            </div>
-        </div>
-        <div class="clr-col" formArrayName="actions">
-            <label class="clr-control-label">{{ 'marketing.actions' | translate }}</label>
-            <vdr-configurable-input
-                *ngFor="let action of actions; index as i"
-                (remove)="removeAction($event)"
-                [position]="i"
-                [operation]="action"
-                [readonly]="!('UpdatePromotion' | hasPermission)"
-                [operationDefinition]="getActionDefinition(action)"
-                [formControlName]="i"
-            ></vdr-configurable-input>
-            <div>
-                <vdr-dropdown *vdrIfPermissions="'UpdatePromotion'">
-                    <button class="btn btn-outline" vdrDropdownTrigger>
-                        <clr-icon shape="plus"></clr-icon>
-                        {{ 'marketing.add-action' | translate }}
-                    </button>
-                    <vdr-dropdown-menu vdrPosition="bottom-left">
-                        <button
-                            *ngFor="let action of getAvailableActions()"
-                            type="button"
-                            vdrDropdownItem
-                            class="item-wrap"
-                            (click)="addAction(action)"
-                        >
-                            {{ action.description }}
+                        <vdr-dropdown-menu vdrPosition="bottom-left">
+                            <button
+                                *ngFor="let condition of getAvailableConditions()"
+                                type="button"
+                                vdrDropdownItem
+                                class="item-wrap"
+                                (click)="addCondition(condition)"
+                            >
+                                {{ condition.description }}
+                            </button>
+                        </vdr-dropdown-menu>
+                    </vdr-dropdown>
+                </div>
+            </vdr-card>
+            <vdr-card [title]="'marketing.actions' | translate" formArrayName="actions">
+                <div class="card-span" *ngFor="let action of actions; index as i">
+                    <vdr-configurable-input
+                        (remove)="removeAction($event)"
+                        [position]="i"
+                        [operation]="action"
+                        [readonly]="!('UpdatePromotion' | hasPermission)"
+                        [operationDefinition]="getActionDefinition(action)"
+                        [formControlName]="i"
+                    ></vdr-configurable-input>
+                </div>
+                <div>
+                    <vdr-dropdown *vdrIfPermissions="'UpdatePromotion'">
+                        <button class="btn btn-outline" vdrDropdownTrigger>
+                            <clr-icon shape="plus"></clr-icon>
+                            {{ 'marketing.add-action' | translate }}
                         </button>
-                    </vdr-dropdown-menu>
-                </vdr-dropdown>
-            </div>
-        </div>
-    </div>
+                        <vdr-dropdown-menu vdrPosition="bottom-left">
+                            <button
+                                *ngFor="let action of getAvailableActions()"
+                                type="button"
+                                vdrDropdownItem
+                                class="item-wrap"
+                                (click)="addAction(action)"
+                            >
+                                {{ action.description }}
+                            </button>
+                        </vdr-dropdown-menu>
+                    </vdr-dropdown>
+                </div>
+            </vdr-card>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
 </form>

+ 75 - 63
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.ts

@@ -1,33 +1,40 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { FormBuilder, UntypedFormArray, UntypedFormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    BaseDetailComponent,
     ConfigurableOperation,
     ConfigurableOperationDefinition,
     ConfigurableOperationInput,
-    CreatePaymentMethodInput,
     CreatePromotionInput,
     createUpdatedTranslatable,
-    CustomFieldConfig,
     DataService,
     encodeConfigArgValue,
     findTranslation,
     getConfigArgValue,
     getDefaultConfigArgValue,
+    GetPromotionDetailDocument,
     LanguageCode,
     NotificationService,
-    PaymentMethodFragment,
+    PROMOTION_FRAGMENT,
     PromotionFragment,
     ServerConfigService,
-    toConfigurableOperationInput,
-    UpdatePaymentMethodInput,
+    TypedBaseDetailComponent,
     UpdatePromotionInput,
 } from '@vendure/admin-ui/core';
-import { combineLatest, Observable } from 'rxjs';
+import { gql } from 'apollo-angular';
+import { combineLatest } from 'rxjs';
 import { mergeMap, take } from 'rxjs/operators';
 
+export const GET_PROMOTION_DETAIL = gql`
+    query GetPromotionDetail($id: ID!) {
+        promotion(id: $id) {
+            ...Promotion
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-promotion-detail',
     templateUrl: './promotion-detail.component.html',
@@ -35,12 +42,24 @@ import { mergeMap, take } from 'rxjs/operators';
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class PromotionDetailComponent
-    extends BaseDetailComponent<PromotionFragment>
+    extends TypedBaseDetailComponent<typeof GetPromotionDetailDocument, 'promotion'>
     implements OnInit, OnDestroy
 {
-    promotion$: Observable<PromotionFragment>;
-    detailForm: UntypedFormGroup;
-    customFields: CustomFieldConfig[];
+    customFields = this.getCustomFieldConfig('Promotion');
+    detailForm = this.formBuilder.group({
+        name: ['', Validators.required],
+        description: '',
+        enabled: true,
+        couponCode: null as string | null,
+        perCustomerUsageLimit: null as number | null,
+        startsAt: null,
+        endsAt: null,
+        conditions: this.formBuilder.array([]),
+        actions: this.formBuilder.array([]),
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
     conditions: ConfigurableOperation[] = [];
     actions: ConfigurableOperation[] = [];
 
@@ -53,30 +72,15 @@ export class PromotionDetailComponent
         serverConfigService: ServerConfigService,
         private changeDetector: ChangeDetectorRef,
         protected dataService: DataService,
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         private notificationService: NotificationService,
     ) {
         super(route, router, serverConfigService, dataService);
         this.customFields = this.getCustomFieldConfig('Promotion');
-        this.detailForm = this.formBuilder.group({
-            name: ['', Validators.required],
-            description: '',
-            enabled: true,
-            couponCode: null,
-            perCustomerUsageLimit: null,
-            startsAt: null,
-            endsAt: null,
-            conditions: this.formBuilder.array([]),
-            actions: this.formBuilder.array([]),
-            customFields: this.formBuilder.group(
-                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-            ),
-        });
     }
 
     ngOnInit() {
         this.init();
-        this.promotion$ = this.entity$;
         this.dataService.promotion.getPromotionActionsAndConditions().single$.subscribe(data => {
             this.allActions = data.promotionActions;
             this.allConditions = data.promotionConditions;
@@ -105,7 +109,7 @@ export class PromotionDetailComponent
     }
 
     saveButtonEnabled(): boolean {
-        return (
+        return !!(
             this.detailForm.dirty &&
             this.detailForm.valid &&
             (this.conditions.length !== 0 || this.detailForm.value.couponCode) &&
@@ -141,40 +145,48 @@ export class PromotionDetailComponent
         if (!this.detailForm.dirty) {
             return;
         }
-        combineLatest(this.entity$, this.languageCode$)
-            .pipe(
-                take(1),
-                mergeMap(([promotion, languageCode]) => {
-                    const input = this.getUpdatedPromotion(
-                        promotion,
-                        this.detailForm,
-                        languageCode,
-                    ) as CreatePromotionInput;
-                    return this.dataService.promotion.createPromotion(input);
-                }),
-            )
-            .subscribe(
-                ({ createPromotion }) => {
-                    switch (createPromotion.__typename) {
-                        case 'Promotion':
-                            this.notificationService.success(_('common.notify-create-success'), {
-                                entity: 'Promotion',
-                            });
-                            this.detailForm.markAsPristine();
-                            this.changeDetector.markForCheck();
-                            this.router.navigate(['../', createPromotion.id], { relativeTo: this.route });
-                            break;
-                        case 'MissingConditionsError':
-                            this.notificationService.error(createPromotion.message);
-                            break;
-                    }
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-create-error'), {
-                        entity: 'Promotion',
-                    });
-                },
-            );
+
+        const input = this.getUpdatedPromotion(
+            {
+                id: '',
+                createdAt: '',
+                updatedAt: '',
+                startsAt: '',
+                endsAt: '',
+                name: '',
+                description: '',
+                couponCode: null,
+                perCustomerUsageLimit: null,
+                enabled: false,
+                conditions: [],
+                actions: [],
+                translations: [],
+            },
+            this.detailForm,
+            this.languageCode,
+        ) as CreatePromotionInput;
+        this.dataService.promotion.createPromotion(input).subscribe(
+            ({ createPromotion }) => {
+                switch (createPromotion.__typename) {
+                    case 'Promotion':
+                        this.notificationService.success(_('common.notify-create-success'), {
+                            entity: 'Promotion',
+                        });
+                        this.detailForm.markAsPristine();
+                        this.changeDetector.markForCheck();
+                        this.router.navigate(['../', createPromotion.id], { relativeTo: this.route });
+                        break;
+                    case 'MissingConditionsError':
+                        this.notificationService.error(createPromotion.message);
+                        break;
+                }
+            },
+            err => {
+                this.notificationService.error(_('common.notify-create-error'), {
+                    entity: 'Promotion',
+                });
+            },
+        );
     }
 
     save() {

+ 19 - 1
packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.html

@@ -1,5 +1,23 @@
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <vdr-language-selector
+                   [availableLanguageCodes]="availableLanguages$ | async"
+                   [currentLanguageCode]="contentLanguage$ | async"
+                   (languageCodeChange)="setLanguage($event)"
+               ></vdr-language-selector>
+        </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="customer-list"></vdr-action-bar-items>
+            <a class="btn btn-primary" [routerLink]="['./create']" *vdrIfPermissions="'CreatePromotion'">
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'marketing.create-new-promotion' | translate }}
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+
 <vdr-data-table-2
-    class="mt-2"
     id="promotion-list"
     [items]="items$ | async"
     [itemsPerPage]="itemsPerPage$ | async"

+ 79 - 94
packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.ts

@@ -1,24 +1,26 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    BaseListComponent,
-    DataService,
-    DataTableService,
-    GetPromotionListQuery,
-    ItemOf,
+    GetPromotionListDocument,
     LogicalOperator,
-    NavBuilderService,
-    PromotionFilterParameter,
+    PROMOTION_FRAGMENT,
     PromotionListOptions,
     PromotionSortParameter,
-    ServerConfigService,
+    TypedBaseListComponent,
 } from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
 
-export type PromotionSearchForm = {
-    name: string;
-    couponCode: string;
-};
+export const GET_PROMOTION_LIST = gql`
+    query GetPromotionList($options: PromotionListOptions) {
+        promotions(options: $options) {
+            items {
+                ...Promotion
+            }
+            totalItems
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;
 
 @Component({
     selector: 'vdr-promotion-list',
@@ -27,98 +29,81 @@ export type PromotionSearchForm = {
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class PromotionListComponent
-    extends BaseListComponent<GetPromotionListQuery, ItemOf<GetPromotionListQuery, 'promotions'>>
+    extends TypedBaseListComponent<typeof GetPromotionListDocument, 'promotions'>
     implements OnInit
 {
-    readonly customFields = this.serverConfigService.getCustomFieldsFor('Promotion');
-    readonly filters = this.dataTableService
-        .createFilterCollection<PromotionFilterParameter>()
+    readonly customFields = this.getCustomFieldConfig('Promotion');
+    readonly filters = this.createFilterCollection()
         .addDateFilters()
-        .addFilter({
-            name: 'startsAt',
-            type: { kind: 'dateRange' },
-            label: _('marketing.starts-at'),
-            filterField: 'startsAt',
-        })
-        .addFilter({
-            name: 'endsAt',
-            type: { kind: 'dateRange' },
-            label: _('marketing.ends-at'),
-            filterField: 'endsAt',
-        })
-        .addFilter({
-            name: 'enabled',
-            type: { kind: 'boolean' },
-            label: _('common.enabled'),
-            filterField: 'enabled',
-        })
-        .addFilter({
-            name: 'name',
-            type: { kind: 'text' },
-            label: _('common.name'),
-            filterField: 'name',
-        })
-        .addFilter({
-            name: 'couponCode',
-            type: { kind: 'text' },
-            label: _('marketing.coupon-code'),
-            filterField: 'couponCode',
-        })
-        .addFilter({
-            name: 'desc',
-            type: { kind: 'text' },
-            label: _('common.description'),
-            filterField: 'description',
-        })
-        .addFilter({
-            name: 'usageLimit',
-            type: { kind: 'number' },
-            label: _('marketing.per-customer-limit'),
-            filterField: 'perCustomerUsageLimit',
-        })
+        .addFilters([
+            {
+                name: 'startsAt',
+                type: { kind: 'dateRange' },
+                label: _('marketing.starts-at'),
+                filterField: 'startsAt',
+            },
+            {
+                name: 'endsAt',
+                type: { kind: 'dateRange' },
+                label: _('marketing.ends-at'),
+                filterField: 'endsAt',
+            },
+            {
+                name: 'enabled',
+                type: { kind: 'boolean' },
+                label: _('common.enabled'),
+                filterField: 'enabled',
+            },
+            {
+                name: 'name',
+                type: { kind: 'text' },
+                label: _('common.name'),
+                filterField: 'name',
+            },
+            {
+                name: 'couponCode',
+                type: { kind: 'text' },
+                label: _('marketing.coupon-code'),
+                filterField: 'couponCode',
+            },
+            {
+                name: 'desc',
+                type: { kind: 'text' },
+                label: _('common.description'),
+                filterField: 'description',
+            },
+            {
+                name: 'usageLimit',
+                type: { kind: 'number' },
+                label: _('marketing.per-customer-limit'),
+                filterField: 'perCustomerUsageLimit',
+            },
+        ])
         .addCustomFieldFilters(this.customFields)
         .connectToRoute(this.route);
 
-    readonly sorts = this.dataTableService
-        .createSortCollection<PromotionSortParameter>()
+    readonly sorts = this.createSortCollection()
         .defaultSort('createdAt', 'DESC')
-        .addSort({ name: 'createdAt' })
-        .addSort({ name: 'updatedAt' })
-        .addSort({ name: 'startsAt' })
-        .addSort({ name: 'endsAt' })
-        .addSort({ name: 'name' })
-        .addSort({ name: 'couponCode' })
-        .addSort({ name: 'perCustomerUsageLimit' })
+        .addSorts([
+            { name: 'createdAt' },
+            { name: 'updatedAt' },
+            { name: 'startsAt' },
+            { name: 'endsAt' },
+            { name: 'name' },
+            { name: 'couponCode' },
+            { name: 'perCustomerUsageLimit' },
+        ])
         .addCustomFieldSorts(this.customFields)
         .connectToRoute(this.route);
 
-    constructor(
-        router: Router,
-        route: ActivatedRoute,
-        navBuilderService: NavBuilderService,
-        private serverConfigService: ServerConfigService,
-        private dataService: DataService,
-        private dataTableService: DataTableService,
-    ) {
-        super(router, route);
-        navBuilderService.addActionBarItem({
-            id: 'create-promotion',
-            label: _('marketing.create-new-promotion'),
-            locationId: 'promotion-list',
-            icon: 'plus',
-            routerLink: ['./create'],
-            requiresPermission: ['CreatePromotion'],
+    constructor() {
+        super();
+        super.configure({
+            document: GetPromotionListDocument,
+            getItems: data => data.promotions,
+            setVariables: (skip, take) => this.createQueryOptions(skip, take, this.searchTermControl.value),
+            refreshListOnChanges: [this.filters.valueChanges, this.sorts.valueChanges],
         });
-        super.setQueryFn(
-            (...args: any[]) => this.dataService.promotion.getPromotions(...args).refetchOnChannelChange(),
-            data => data.promotions,
-            (skip, take) => this.createQueryOptions(skip, take, this.searchTermControl.value),
-        );
-    }
-
-    ngOnInit(): void {
-        super.ngOnInit();
-        super.refreshListOnChanges(this.filters.valueChanges, this.sorts.valueChanges);
     }
 
     private createQueryOptions(

+ 25 - 2
packages/admin-ui/src/lib/marketing/src/marketing.module.ts

@@ -1,7 +1,14 @@
+import { AsyncPipe } from '@angular/common';
 import { NgModule } from '@angular/core';
 import { RouterModule, ROUTES } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { BulkActionRegistryService, PageService, SharedModule } from '@vendure/admin-ui/core';
+import {
+    BulkActionRegistryService,
+    detailComponentWithResolver,
+    GetPromotionDetailDocument,
+    PageService,
+    SharedModule,
+} from '@vendure/admin-ui/core';
 
 import { PromotionDetailComponent } from './components/promotion-detail/promotion-detail.component';
 import { deletePromotionsBulkAction } from './components/promotion-list/promotion-list-bulk-actions';
@@ -9,7 +16,7 @@ import { PromotionListComponent } from './components/promotion-list/promotion-li
 import { createRoutes } from './marketing.routes';
 
 @NgModule({
-    imports: [SharedModule, RouterModule.forChild([])],
+    imports: [SharedModule, RouterModule.forChild([]), SharedModule, AsyncPipe, SharedModule],
     providers: [
         {
             provide: ROUTES,
@@ -30,5 +37,21 @@ export class MarketingModule {
             route: '',
             component: PromotionListComponent,
         });
+        pageService.registerPageTab({
+            location: 'promotion-detail',
+            tab: _('marketing.promotion'),
+            route: '',
+            component: detailComponentWithResolver({
+                component: PromotionDetailComponent,
+                query: GetPromotionDetailDocument,
+                entityKey: 'promotion',
+                getBreadcrumbs: entity => [
+                    {
+                        label: entity ? entity.name : _('marketing.create-new-promotion'),
+                        link: [entity?.id],
+                    },
+                ],
+            }),
+        });
     }
 }

+ 5 - 16
packages/admin-ui/src/lib/marketing/src/marketing.routes.ts

@@ -1,17 +1,6 @@
 import { Route } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import {
-    CanDeactivateDetailGuard,
-    createResolveData,
-    detailBreadcrumb,
-    PageComponent,
-    PageService,
-    PromotionFragment,
-} from '@vendure/admin-ui/core';
-
-import { PromotionDetailComponent } from './components/promotion-detail/promotion-detail.component';
-import { PromotionListComponent } from './components/promotion-list/promotion-list.component';
-import { PromotionResolver } from './providers/routing/promotion-resolver';
+import { detailBreadcrumb, PageComponent, PageService, PromotionFragment } from '@vendure/admin-ui/core';
 
 export const createRoutes = (pageService: PageService): Route[] => [
     {
@@ -25,12 +14,12 @@ export const createRoutes = (pageService: PageService): Route[] => [
     },
     {
         path: 'promotions/:id',
-        component: PromotionDetailComponent,
-        resolve: createResolveData(PromotionResolver),
-        canDeactivate: [CanDeactivateDetailGuard],
+        component: PageComponent,
         data: {
-            breadcrumb: promotionBreadcrumb,
+            locationId: 'promotion-detail',
+            breadcrumb: { label: _('breadcrumb.promotions'), link: ['../', 'promotions'] },
         },
+        children: pageService.getPageTabRoutes('promotion-detail'),
     },
 ];
 

+ 0 - 31
packages/admin-ui/src/lib/marketing/src/providers/routing/promotion-resolver.ts

@@ -1,31 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import { BaseEntityResolver, DataService, PromotionFragment } from '@vendure/admin-ui/core';
-
-/**
- * Resolves the id from the path into a Customer entity.
- */
-@Injectable({
-    providedIn: 'root',
-})
-export class PromotionResolver extends BaseEntityResolver<PromotionFragment> {
-    constructor(router: Router, dataService: DataService) {
-        super(
-            router,
-            {
-                __typename: 'Promotion',
-                id: '',
-                createdAt: '',
-                updatedAt: '',
-                name: '',
-                description: '',
-                couponCode: '',
-                enabled: false,
-                conditions: [],
-                actions: [],
-                translations: [],
-            },
-            id => dataService.promotion.getPromotion(id).mapStream(data => data.promotion),
-        );
-    }
-}

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

@@ -4,4 +4,3 @@ 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';
-export * from './providers/routing/promotion-resolver';

+ 28 - 3
packages/admin-ui/src/lib/order/src/components/add-manual-payment-dialog/add-manual-payment-dialog.component.ts

@@ -4,12 +4,33 @@ import {
     CurrencyCode,
     DataService,
     Dialog,
+    GetAddManualPaymentMethodListDocument,
+    GetAddManualPaymentMethodListQuery,
     GetPaymentMethodListQuery,
     ItemOf,
     ManualPaymentInput,
+    PAYMENT_METHOD_FRAGMENT,
 } from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
 import { Observable } from 'rxjs';
 
+const GET_PAYMENT_METHODS_FOR_MANUAL_ADD = gql`
+    query GetAddManualPaymentMethodList($options: PaymentMethodListOptions!) {
+        paymentMethods(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+                code
+                description
+                enabled
+            }
+            totalItems
+        }
+    }
+`;
+
 @Component({
     selector: 'vdr-add-manual-payment-dialog',
     templateUrl: './add-manual-payment-dialog.component.html',
@@ -26,12 +47,16 @@ export class AddManualPaymentDialogComponent implements OnInit, Dialog<Omit<Manu
         method: new UntypedFormControl('', Validators.required),
         transactionId: new UntypedFormControl('', Validators.required),
     });
-    paymentMethods$: Observable<Array<ItemOf<GetPaymentMethodListQuery, 'paymentMethods'>>>;
+    paymentMethods$: Observable<Array<ItemOf<GetAddManualPaymentMethodListQuery, 'paymentMethods'>>>;
     constructor(private dataService: DataService) {}
 
     ngOnInit(): void {
-        this.paymentMethods$ = this.dataService.settings
-            .getPaymentMethods(999)
+        this.paymentMethods$ = this.dataService
+            .query(GetAddManualPaymentMethodListDocument, {
+                options: {
+                    take: 999,
+                },
+            })
             .mapSingle(data => data.paymentMethods.items);
     }
 

+ 29 - 4
packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts

@@ -1,8 +1,26 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 import { UntypedFormControl } from '@angular/forms';
-import { DataService } from '@vendure/admin-ui/core';
+import {
+    DataService,
+    GetCouponCodeSelectorPromotionListDocument,
+    PROMOTION_FRAGMENT,
+} from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
 import { concat, Observable, Subject } from 'rxjs';
-import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
+import { debounceTime, distinctUntilChanged, map, skip, startWith, switchMap } from 'rxjs/operators';
+
+export const GET_COUPON_CODE_SELECTOR_PROMOTION_LIST = gql`
+    query GetCouponCodeSelectorPromotionList($options: PromotionListOptions) {
+        promotions(options: $options) {
+            items {
+                id
+                name
+                couponCode
+            }
+            totalItems
+        }
+    }
+`;
 
 @Component({
     selector: 'vdr-coupon-code-selector',
@@ -22,11 +40,18 @@ export class CouponCodeSelectorComponent implements OnInit {
     ngOnInit(): void {
         this.availableCouponCodes$ = concat(
             this.couponCodeInput$.pipe(
+                debounceTime(200),
                 distinctUntilChanged(),
                 switchMap(
                     term =>
-                        this.dataService.promotion.getPromotions(10, 0, {
-                            couponCode: { contains: term },
+                        this.dataService.query(GetCouponCodeSelectorPromotionListDocument, {
+                            options: {
+                                take: 10,
+                                skip: 0,
+                                filter: {
+                                    couponCode: { contains: term },
+                                },
+                            },
                         }).single$,
                 ),
                 map(({ promotions }) =>

+ 170 - 149
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html

@@ -1,116 +1,169 @@
-<vdr-action-bar *ngIf="entity$ | async as order">
-    <vdr-ab-left>
-        <div class="flex clr-align-items-center">
-            <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-            <vdr-order-state-label [state]="order.state">
-                <button
-                    class="icon-button"
-                    (click)="openStateDiagram()"
-                    [title]="'order.order-state-diagram' | translate"
-                >
-                    <clr-icon shape="list"></clr-icon>
-                </button>
-            </vdr-order-state-label>
-        </div>
-    </vdr-ab-left>
+<vdr-page-block>
+    <vdr-action-bar *ngIf="entity$ | async as order">
+        <vdr-ab-left> </vdr-ab-left>
 
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="order-detail"></vdr-action-bar-items>
-        <button
-            class="btn btn-primary"
-            *ngIf="
-            order.type !== 'Aggregate' &&
-                (order.state === 'ArrangingPayment' || order.state === 'ArrangingAdditionalPayment') &&
-                (hasUnsettledModifications(order) || 0 < outstandingPaymentAmount(order))
-            "
-            (click)="addManualPayment(order)"
-        >
-            {{ 'order.add-payment-to-order' | translate }}
-            ({{ outstandingPaymentAmount(order) | localeCurrency: order.currencyCode }})
-        </button>
-        <button
-            class="btn btn-primary"
-            *ngIf="
-                order.type !== 'Aggregate' &&
-                order.active === false &&
-                order.state !== 'ArrangingAdditionalPayment' &&
-                order.state !== 'ArrangingPayment' &&
-                0 < outstandingPaymentAmount(order)
-            "
-            (click)="transitionToState('ArrangingAdditionalPayment')"
-        >
-            {{ 'order.arrange-additional-payment' | translate }}
-        </button>
-        <button *ngIf="order.type !== 'Aggregate'" class="btn btn-primary" (click)="fulfillOrder()" [disabled]="!canAddFulfillment(order)">
-            {{ 'order.fulfill-order' | translate }}
-        </button>
-        <vdr-dropdown>
-            <button class="icon-button" vdrDropdownTrigger>
-                <clr-icon shape="ellipsis-vertical"></clr-icon>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="order-detail"></vdr-action-bar-items>
+            <button
+                class="btn btn-primary"
+                *ngIf="
+                    order.type !== 'Aggregate' &&
+                    (order.state === 'ArrangingPayment' || order.state === 'ArrangingAdditionalPayment') &&
+                    (hasUnsettledModifications(order) || 0 < outstandingPaymentAmount(order))
+                "
+                (click)="addManualPayment(order)"
+            >
+                {{ 'order.add-payment-to-order' | translate }}
+                ({{ outstandingPaymentAmount(order) | localeCurrency : order.currencyCode }})
             </button>
-            <vdr-dropdown-menu vdrPosition="bottom-right">
-                <ng-container *ngIf="order.type !== 'Aggregate' && order.nextStates.includes('Modifying')">
-                    <button type="button" class="btn" vdrDropdownItem (click)="transitionToModifying()">
-                        <clr-icon shape="pencil"></clr-icon>
-                        {{ 'order.modify-order' | translate }}
-                    </button>
-                    <div class="dropdown-divider"></div>
-                </ng-container>
-                <button
-                    type="button"
-                    class="btn"
-                    vdrDropdownItem
-                    *ngIf="order.type !== 'Aggregate' && order.nextStates.includes('Cancelled')"
-                    (click)="cancelOrRefund(order)"
-                >
-                    <clr-icon shape="error-standard" class="is-error"></clr-icon>
-                    <ng-container *ngIf="orderHasSettledPayments(order); else cancelOnly">
-                        {{ 'order.refund-and-cancel-order' | translate }}
-                    </ng-container>
-                    <ng-template #cancelOnly>
-                        {{ 'order.cancel-order' | translate }}
-                    </ng-template>
+            <button
+                class="btn btn-primary"
+                *ngIf="
+                    order.type !== 'Aggregate' &&
+                    order.active === false &&
+                    order.state !== 'ArrangingAdditionalPayment' &&
+                    order.state !== 'ArrangingPayment' &&
+                    0 < outstandingPaymentAmount(order)
+                "
+                (click)="transitionToState('ArrangingAdditionalPayment')"
+            >
+                {{ 'order.arrange-additional-payment' | translate }}
+            </button>
+            <button
+                *ngIf="order.type !== 'Aggregate'"
+                class="btn btn-primary"
+                (click)="fulfillOrder()"
+                [disabled]="!canAddFulfillment(order)"
+            >
+                {{ 'order.fulfill-order' | translate }}
+            </button>
+            <vdr-dropdown>
+                <button class="icon-button" vdrDropdownTrigger>
+                    <clr-icon shape="ellipsis-vertical"></clr-icon>
                 </button>
+                <vdr-dropdown-menu vdrPosition="bottom-right">
+                    <ng-container
+                        *ngIf="order.type !== 'Aggregate' && order.nextStates.includes('Modifying')"
+                    >
+                        <button type="button" class="btn" vdrDropdownItem (click)="transitionToModifying()">
+                            <clr-icon shape="pencil"></clr-icon>
+                            {{ 'order.modify-order' | translate }}
+                        </button>
+                        <div class="dropdown-divider"></div>
+                    </ng-container>
+                    <button
+                        type="button"
+                        class="btn"
+                        vdrDropdownItem
+                        *ngIf="order.type !== 'Aggregate' && order.nextStates.includes('Cancelled')"
+                        (click)="cancelOrRefund(order)"
+                    >
+                        <clr-icon shape="error-standard" class="is-error"></clr-icon>
+                        <ng-container *ngIf="orderHasSettledPayments(order); else cancelOnly">
+                            {{ 'order.refund-and-cancel-order' | translate }}
+                        </ng-container>
+                        <ng-template #cancelOnly>
+                            {{ 'order.cancel-order' | translate }}
+                        </ng-template>
+                    </button>
 
-                <ng-container *ngIf="(nextStates$ | async)?.length">
+                    <ng-container *ngIf="(nextStates$ | async)?.length">
+                        <div class="dropdown-divider"></div>
+                        <button
+                            *ngFor="let nextState of nextStates$ | async"
+                            type="button"
+                            class="btn"
+                            vdrDropdownItem
+                            (click)="transitionToState(nextState)"
+                        >
+                            <clr-icon shape="step-forward-2"></clr-icon>
+                            {{
+                                'order.transition-to-state'
+                                    | translate : { state: (nextState | stateI18nToken | translate) }
+                            }}
+                        </button>
+                    </ng-container>
                     <div class="dropdown-divider"></div>
                     <button
-                        *ngFor="let nextState of nextStates$ | async"
                         type="button"
                         class="btn"
                         vdrDropdownItem
-                        (click)="transitionToState(nextState)"
+                        (click)="manuallyTransitionToState(order)"
                     >
-                        <clr-icon shape="step-forward-2"></clr-icon>
-                        {{
-                            'order.transition-to-state'
-                                | translate: { state: (nextState | stateI18nToken | translate) }
-                        }}
+                        <clr-icon shape="step-forward-2" class="is-warning"></clr-icon>
+                        {{ 'order.manually-transition-to-state' | translate }}
                     </button>
-                </ng-container>
-                <div class="dropdown-divider"></div>
-                <button type="button" class="btn" vdrDropdownItem (click)="manuallyTransitionToState(order)">
-                    <clr-icon shape="step-forward-2" class="is-warning"></clr-icon>
-                    {{ 'order.manually-transition-to-state' | translate }}
+                </vdr-dropdown-menu>
+            </vdr-dropdown>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+
+<vdr-page-detail-layout *ngIf="entity$ | async as order">
+    <vdr-page-detail-sidebar>
+        <vdr-card>
+            <vdr-order-state-label [state]="order.state">
+                <button
+                    class="icon-button"
+                    (click)="openStateDiagram()"
+                    [title]="'order.order-state-diagram' | translate"
+                >
+                    <clr-icon shape="list"></clr-icon>
                 </button>
-            </vdr-dropdown-menu>
-        </vdr-dropdown>
-    </vdr-ab-right>
-</vdr-action-bar>
+            </vdr-order-state-label>
+        </vdr-card>
+        <vdr-card [title]="'order.customer' | translate">
+            <vdr-customer-label [customer]="order.customer"></vdr-customer-label>
+            <vdr-labeled-data
+                [label]="'order.shipping-address' | translate"
+                *ngIf="getOrderAddressLines(order.shippingAddress).length"
+            >
+                <vdr-formatted-address [address]="order.shippingAddress" class="mt-1"></vdr-formatted-address>
+            </vdr-labeled-data>
+            <vdr-labeled-data
+                [label]="'order.billing-address' | translate"
+                *ngIf="getOrderAddressLines(order.billingAddress).length"
+            >
+                <vdr-formatted-address [address]="order.billingAddress" class="mt-1"></vdr-formatted-address>
+            </vdr-labeled-data>
+        </vdr-card>
+        <vdr-card [title]="'order.payments' | translate" *ngIf="order.payments?.length">
+            <vdr-order-payment-card
+                *ngFor="let payment of order.payments"
+                [currencyCode]="order.currencyCode"
+                [payment]="payment"
+                (settlePayment)="settlePayment($event)"
+                (transitionPaymentState)="transitionPaymentState($event)"
+                (settleRefund)="settleRefund($event)"
+            ></vdr-order-payment-card>
+        </vdr-card>
+        <vdr-card *ngIf="order.fulfillments?.length">
+            <vdr-fulfillment-card
+                *ngFor="let fulfillment of order.fulfillments"
+                [fulfillment]="fulfillment"
+                [order]="order"
+                (transitionState)="transitionFulfillment(fulfillment.id, $event)"
+            ></vdr-fulfillment-card>
+        </vdr-card>
+        <vdr-card>
+            <vdr-page-entity-info *ngIf="entity$ | async as entity" [entity]="entity" />
+        </vdr-card>
+    </vdr-page-detail-sidebar>
 
-<div *ngIf="entity$ | async as order">
-    <div class="clr-row">
-        <div class="clr-col-lg-8">
-            <vdr-seller-orders-card
-                *ngIf="order.sellerOrders.length"
-                [orderId]="order.id"
-            ></vdr-seller-orders-card>
+    <vdr-page-block>
+        <vdr-seller-orders-card
+            *ngIf="order.sellerOrders.length"
+            [orderId]="order.id"
+        ></vdr-seller-orders-card>
+        <vdr-card [paddingX]="true">
             <vdr-order-table
+                class="card-span"
                 [order]="order"
                 [orderLineCustomFields]="orderLineCustomFields"
             ></vdr-order-table>
-            <h4>{{ 'order.tax-summary' | translate }}</h4>
-            <table class="table">
+        </vdr-card>
+        <vdr-card [title]="'order.tax-summary' | translate" [paddingX]="true">
+            <table class="table card-span">
                 <thead>
                     <tr>
                         <th>{{ 'common.description' | translate }}</th>
@@ -123,67 +176,35 @@
                     <tr *ngFor="let row of order.taxSummary">
                         <td>{{ row.description }}</td>
                         <td>{{ row.taxRate / 100 | percent }}</td>
-                        <td>{{ row.taxBase | localeCurrency: order.currencyCode }}</td>
-                        <td>{{ row.taxTotal | localeCurrency: order.currencyCode }}</td>
+                        <td>{{ row.taxBase | localeCurrency : order.currencyCode }}</td>
+                        <td>{{ row.taxTotal | localeCurrency : order.currencyCode }}</td>
                     </tr>
                 </tbody>
             </table>
+        </vdr-card>
+        <vdr-card [title]="'common.custom-fields' | translate" *ngIf="customFields.length">
+            <vdr-tabbed-custom-fields
+                entityName="Facet"
+                [customFields]="customFields"
+                [customFieldsFormGroup]="detailForm.get(['facet', 'customFields'])"
+                [readonly]="!('UpdateOrder' | hasPermission)"
+            />
+        </vdr-card>
+        <vdr-custom-detail-component-host
+            locationId="order-detail"
+            [entity$]="entity$"
+            [detailForm]="detailForm"
+        ></vdr-custom-detail-component-host>
 
-            <vdr-custom-detail-component-host
-                locationId="order-detail"
-                [entity$]="entity$"
-                [detailForm]="detailForm"
-            ></vdr-custom-detail-component-host>
-
+        <vdr-card [title]="'order.order-history' | translate">
             <vdr-order-history
+                class="card-span"
                 [order]="order"
                 [history]="history$ | async"
                 (addNote)="addNote($event)"
                 (updateNote)="updateNote($event)"
                 (deleteNote)="deleteNote($event)"
             ></vdr-order-history>
-        </div>
-        <div class="clr-col-lg-4 order-cards">
-            <vdr-order-custom-fields-card
-                [customFieldsConfig]="customFields"
-                [customFieldValues]="order.customFields"
-                (updateClick)="updateCustomFields($event)"
-            ></vdr-order-custom-fields-card>
-            <div class="card">
-                <div class="card-header">
-                    {{ 'order.customer' | translate }}
-                </div>
-                <div class="card-block">
-                    <div class="card-text">
-                        <vdr-customer-label [customer]="order.customer"></vdr-customer-label>
-                        <h6 *ngIf="getOrderAddressLines(order.shippingAddress).length">
-                            {{ 'order.shipping-address' | translate }}
-                        </h6>
-                        <vdr-formatted-address [address]="order.shippingAddress"></vdr-formatted-address>
-                        <h6 *ngIf="getOrderAddressLines(order.billingAddress).length">
-                            {{ 'order.billing-address' | translate }}
-                        </h6>
-                        <vdr-formatted-address [address]="order.billingAddress"></vdr-formatted-address>
-                    </div>
-                </div>
-            </div>
-            <ng-container *ngIf="order.payments && order.payments.length">
-                <vdr-order-payment-card
-                    *ngFor="let payment of order.payments"
-                    [currencyCode]="order.currencyCode"
-                    [payment]="payment"
-                    (settlePayment)="settlePayment($event)"
-                    (transitionPaymentState)="transitionPaymentState($event)"
-                    (settleRefund)="settleRefund($event)"
-                ></vdr-order-payment-card>
-            </ng-container>
-            <ng-container *ngFor="let fulfillment of order.fulfillments">
-                <vdr-fulfillment-card
-                    [fulfillment]="fulfillment"
-                    [order]="order"
-                    (transitionState)="transitionFulfillment(fulfillment.id, $event)"
-                ></vdr-fulfillment-card>
-            </ng-container>
-        </div>
-    </div>
-</div>
+        </vdr-card>
+    </vdr-page-block>
+</vdr-page-detail-layout>

+ 14 - 1
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts

@@ -15,16 +15,20 @@ import {
     ModalService,
     NotificationService,
     Order,
+    ORDER_DETAIL_FRAGMENT,
     OrderDetailFragment,
+    OrderDetailQueryDocument,
     OrderLineFragment,
     Refund,
     RefundOrderMutation,
     ServerConfigService,
     SortOrder,
     TimelineHistoryEntry,
+    TypedBaseDetailComponent,
 } from '@vendure/admin-ui/core';
 import { pick } from '@vendure/common/lib/pick';
 import { assertNever, summate } from '@vendure/common/lib/shared-utils';
+import { gql } from 'apollo-angular';
 import { EMPTY, merge, Observable, of, Subject } from 'rxjs';
 import { map, mapTo, startWith, switchMap, take } from 'rxjs/operators';
 
@@ -38,6 +42,15 @@ import { SettleRefundDialogComponent } from '../settle-refund-dialog/settle-refu
 
 type Payment = NonNullable<OrderDetailFragment['payments']>[number];
 
+export const ORDER_DETAIL_QUERY = gql`
+    query OrderDetailQuery($id: ID!) {
+        order(id: $id) {
+            ...OrderDetail
+        }
+    }
+    ${ORDER_DETAIL_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-order-detail',
     templateUrl: './order-detail.component.html',
@@ -45,7 +58,7 @@ type Payment = NonNullable<OrderDetailFragment['payments']>[number];
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class OrderDetailComponent
-    extends BaseDetailComponent<OrderDetailFragment>
+    extends TypedBaseDetailComponent<typeof OrderDetailQueryDocument, 'order'>
     implements OnInit, OnDestroy
 {
     detailForm = new UntypedFormGroup({});

+ 0 - 1
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html

@@ -1,4 +1,3 @@
-<h4>{{ 'order.order-history' | translate }}</h4>
 <div class="entry-list" [class.expanded]="expanded">
     <vdr-timeline-entry iconShape="note" displayType="muted" [featured]="true">
         <div class="note-entry">

+ 1 - 4
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.scss

@@ -1,14 +1,11 @@
 @import 'variables';
 
 :host {
-    margin-top: 48px;
     display: block;
 }
 
 .entry-list {
-    margin-top: 24px;
-    margin-left: 24px;
-    margin-right: 12px;
+    margin-left: calc(var(--space-unit) * 2);
 }
 
 .note-entry {

+ 5 - 0
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html

@@ -18,6 +18,11 @@
         [searchTermControl]="searchTermControl"
         [searchTermPlaceholder]="'order.search-by-order-filters' | translate"
     />
+    <vdr-dt2-column [heading]="'common.id' | translate" [hiddenByDefault]="true" [sort]="sorts.get('id')">
+        <ng-template let-order="item">
+            {{ order.id }}
+        </ng-template>
+    </vdr-dt2-column>
     <vdr-dt2-column [heading]="'common.created-at' | translate" [hiddenByDefault]="true">
         <ng-template let-order="item">
             {{ order.createdAt | localeDate : 'short' }}

+ 1 - 0
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts

@@ -80,6 +80,7 @@ export class OrderListComponent
     readonly sorts = this.dataTableService
         .createSortCollection<OrderSortParameter>()
         .defaultSort('updatedAt', 'DESC')
+        .addSort({ name: 'id' })
         .addSort({ name: 'createdAt' })
         .addSort({ name: 'updatedAt' })
         .addSort({ name: 'orderPlacedAt' })

+ 22 - 1
packages/admin-ui/src/lib/order/src/order.module.ts

@@ -1,7 +1,12 @@
 import { NgModule } from '@angular/core';
 import { RouterModule, ROUTES } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { PageService, SharedModule } from '@vendure/admin-ui/core';
+import {
+    detailComponentWithResolver,
+    OrderDetailQueryDocument,
+    PageService,
+    SharedModule,
+} from '@vendure/admin-ui/core';
 
 import { AddManualPaymentDialogComponent } from './components/add-manual-payment-dialog/add-manual-payment-dialog.component';
 import { CancelOrderDialogComponent } from './components/cancel-order-dialog/cancel-order-dialog.component';
@@ -99,5 +104,21 @@ export class OrderModule {
             route: '',
             component: OrderListComponent,
         });
+        pageService.registerPageTab({
+            location: 'order-detail',
+            tab: _('orders.order'),
+            route: '',
+            component: detailComponentWithResolver({
+                component: OrderDetailComponent,
+                query: OrderDetailQueryDocument,
+                entityKey: 'order',
+                getBreadcrumbs: entity => [
+                    {
+                        label: `${entity?.code}`,
+                        link: [entity?.id],
+                    },
+                ],
+            }),
+        });
     }
 }

+ 5 - 6
packages/admin-ui/src/lib/order/src/order.routes.ts

@@ -21,6 +21,7 @@ export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: '',
         component: PageComponent,
+        pathMatch: 'full',
         data: {
             locationId: 'order-list',
             breadcrumb: _('breadcrumb.orders'),
@@ -41,15 +42,13 @@ export const createRoutes = (pageService: PageService): Route[] => [
     },
     {
         path: ':id',
-        component: OrderDetailComponent,
-        resolve: {
-            entity: OrderResolver,
-        },
+        component: PageComponent,
         canActivate: [OrderGuard],
-        canDeactivate: [CanDeactivateDetailGuard],
         data: {
-            breadcrumb: orderBreadcrumb,
+            locationId: 'order-detail',
+            breadcrumb: { label: _('breadcrumb.orders'), link: ['../'] },
         },
+        children: pageService.getPageTabRoutes('order-detail'),
     },
     {
         path: ':aggregateOrderId/seller-orders/:id',

+ 46 - 7
packages/admin-ui/src/lib/settings/src/components/add-country-to-zone-dialog/add-country-to-zone-dialog.component.ts

@@ -1,7 +1,36 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
-import { DataService, Dialog, GetCountryListQuery, GetZoneListQuery, ItemOf } from '@vendure/admin-ui/core';
+import {
+    DataService,
+    Dialog,
+    GetCountryListDocument,
+    GetCountryListQuery,
+    GetZoneListQuery,
+    GetZoneMembersDocument,
+    GetZoneMembersQuery,
+    ItemOf,
+} from '@vendure/admin-ui/core';
+import { gql } from 'apollo-angular';
 import { Observable } from 'rxjs';
-import { filter, map } from 'rxjs/operators';
+import { map, withLatestFrom } from 'rxjs/operators';
+
+export const GET_ZONE_MEMBERS = gql`
+    query GetZoneMembers($zoneId: ID!) {
+        zone(id: $zoneId) {
+            id
+            createdAt
+            updatedAt
+            name
+            members {
+                createdAt
+                updatedAt
+                id
+                name
+                code
+                enabled
+            }
+        }
+    }
+`;
 
 @Component({
     selector: 'vdr-add-country-to-zone-dialog',
@@ -12,18 +41,28 @@ import { filter, map } from 'rxjs/operators';
 export class AddCountryToZoneDialogComponent implements Dialog<string[]>, OnInit {
     resolveWith: (result?: string[]) => void;
     zoneName: string;
-    currentMembers: ItemOf<GetZoneListQuery, 'zones'>['members'] = [];
+    zoneId: string;
+    currentMembers$: Observable<NonNullable<GetZoneMembersQuery['zone']>['members']>;
     availableCountries$: Observable<Array<ItemOf<GetCountryListQuery, 'countries'>>>;
     selectedMemberIds: string[] = [];
 
     constructor(private dataService: DataService) {}
 
     ngOnInit(): void {
-        const currentMemberIds = this.currentMembers.map(m => m.id);
-        this.availableCountries$ = this.dataService.settings
-            .getCountries(999)
+        this.currentMembers$ = this.dataService
+            .query(GetZoneMembersDocument, { zoneId: this.zoneId })
+            .mapSingle(({ zone }) => zone?.members ?? []);
+        this.availableCountries$ = this.dataService
+            .query(GetCountryListDocument, {
+                options: { take: 999 },
+            })
             .mapStream(data => data.countries.items)
-            .pipe(map(countries => countries.filter(c => !currentMemberIds.includes(c.id))));
+            .pipe(
+                withLatestFrom(this.currentMembers$),
+                map(([countries, currentMembers]) =>
+                    countries.filter(c => !currentMembers.find(cm => cm.id === c.id)),
+                ),
+            );
     }
 
     cancel() {

+ 126 - 97
packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.html

@@ -1,107 +1,136 @@
-<vdr-action-bar>
-    <vdr-ab-left>
-        <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-    </vdr-ab-left>
-    <vdr-ab-right>
-        <vdr-action-bar-items locationId="administrator-detail"></vdr-action-bar-items>
-        <button
-            class="btn btn-primary"
-            *ngIf="isNew$ | async; else updateButton"
-            (click)="create()"
-            [disabled]="detailForm.invalid || detailForm.pristine"
-        >
-            {{ 'common.create' | translate }}
-        </button>
-        <ng-template #updateButton>
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left> </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="administrator-detail"></vdr-action-bar-items>
             <button
                 class="btn btn-primary"
-                (click)="save()"
-                *vdrIfPermissions="'UpdateAdministrator'"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
                 [disabled]="detailForm.invalid || detailForm.pristine"
             >
-                {{ 'common.update' | translate }}
+                {{ 'common.create' | translate }}
             </button>
-        </ng-template>
-    </vdr-ab-right>
-</vdr-action-bar>
+            <ng-template #updateButton>
+                <button
+                    class="btn btn-primary"
+                    (click)="save()"
+                    *vdrIfPermissions="'UpdateAdministrator'"
+                    [disabled]="detailForm.invalid || detailForm.pristine"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
 
 <form class="form" [formGroup]="detailForm">
-    <vdr-form-field [label]="'settings.email-address' | translate" for="emailAddress">
-        <input
-            id="emailAddress"
-            type="text"
-            formControlName="emailAddress"
-            [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
-        />
-    </vdr-form-field>
-    <vdr-form-field [label]="'settings.first-name' | translate" for="firstName">
-        <input
-            id="firstName"
-            type="text"
-            formControlName="firstName"
-            [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
-        />
-    </vdr-form-field>
-    <vdr-form-field [label]="'settings.last-name' | translate" for="lastName">
-        <input
-            id="lastName"
-            type="text"
-            formControlName="lastName"
-            [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
-        />
-    </vdr-form-field>
-    <vdr-form-field *ngIf="isNew$ | async" [label]="'settings.password' | translate" for="password">
-        <input id="password" type="password" formControlName="password" />
-    </vdr-form-field>
-    <vdr-form-field
-        *ngIf="!(isNew$ | async) && (['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
-        [label]="'settings.password' | translate"
-        for="password"
-        [readOnlyToggle]="true"
-    >
-        <input id="password" type="password" formControlName="password" />
-    </vdr-form-field>
-    <section formGroupName="customFields" *ngIf="customFields.length">
-        <label>{{ 'common.custom-fields' | translate }}</label>
-        <vdr-tabbed-custom-fields
-            entityName="Administrator"
-            [customFields]="customFields"
-            [customFieldsFormGroup]="detailForm.get('customFields')"
-            [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
-        ></vdr-tabbed-custom-fields>
-    </section>
-    <vdr-custom-detail-component-host
-        locationId="administrator-detail"
-        [entity$]="entity$"
-        [detailForm]="detailForm"
-    ></vdr-custom-detail-component-host>
-    <label class="clr-control-label">{{ 'settings.roles' | translate }}</label>
-    <ng-select
-        [items]="allRoles$ | async"
-        [multiple]="true"
-        [hideSelected]="true"
-        formControlName="roles"
-        (change)="rolesChanged($event)"
-        bindLabel="description"
-    ></ng-select>
+    <vdr-page-detail-layout>
+        <vdr-page-detail-sidebar>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+        <vdr-page-block>
+            <vdr-card>
+                <vdr-form-field
+                    [label]="'settings.email-address-or-identifier' | translate"
+                    for="emailAddress"
+                    class="card-span"
+                >
+                    <input
+                        id="emailAddress"
+                        type="text"
+                        formControlName="emailAddress"
+                        [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
+                    />
+                </vdr-form-field>
+                <vdr-form-field [label]="'settings.first-name' | translate" for="firstName">
+                    <input
+                        id="firstName"
+                        type="text"
+                        formControlName="firstName"
+                        [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
+                    />
+                </vdr-form-field>
+                <vdr-form-field [label]="'settings.last-name' | translate" for="lastName">
+                    <input
+                        id="lastName"
+                        type="text"
+                        formControlName="lastName"
+                        [readonly]="!(['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)"
+                    />
+                </vdr-form-field>
+                <vdr-form-field
+                    *ngIf="isNew$ | async"
+                    [label]="'settings.password' | translate"
+                    for="password"
+                >
+                    <input id="password" type="password" formControlName="password" />
+                </vdr-form-field>
+                <vdr-form-field
+                    *ngIf="
+                        !(isNew$ | async) && (['CreateAdministrator', 'UpdateAdministrator'] | hasPermission)
+                    "
+                    [label]="'settings.password' | translate"
+                    for="password"
+                    [readOnlyToggle]="true"
+                >
+                    <input id="password" type="password" formControlName="password" />
+                </vdr-form-field>
+            </vdr-card>
 
-    <ul class="nav" role="tablist">
-        <li role="presentation" class="nav-item" *ngFor="let channel of getAvailableChannels()">
-            <button
-                [id]="channel.channelId"
-                (click)="selectedChannelId = channel.channelId"
-                class="btn btn-link nav-link"
-                [class.active]="selectedChannelId === channel.channelId"
-                [attr.aria-selected]="selectedChannelId === channel.channelId"
-                type="button"
+            <vdr-card
+                formGroupName="customFields"
+                *ngIf="customFields.length"
+                [title]="'common.custom-fields' | translate"
             >
-                {{ channel.channelCode | channelCodeToLabel | translate }}
-            </button>
-        </li>
-    </ul>
-    <vdr-permission-grid
-        [activePermissions]="getPermissionsForSelectedChannel()"
-        [permissionDefinitions]="permissionDefinitions"
-        [readonly]="true"
-    ></vdr-permission-grid>
+                <vdr-tabbed-custom-fields
+                    entityName="Customer"
+                    [customFields]="customFields"
+                    [customFieldsFormGroup]="detailForm.get(['customFields'])"
+                ></vdr-tabbed-custom-fields>
+            </vdr-card>
+            <vdr-custom-detail-component-host
+                locationId="administrator-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
+    <vdr-page-block>
+        <vdr-card [title]="'settings.roles' | translate">
+            <ng-select
+                class="card-span"
+                [items]="allRoles$ | async"
+                [multiple]="true"
+                [hideSelected]="true"
+                formControlName="roles"
+                (change)="rolesChanged($event)"
+                bindLabel="description"
+            ></ng-select>
+
+            <ul class="nav card-span" role="tablist">
+                <li role="presentation" class="nav-item" *ngFor="let channel of getAvailableChannels()">
+                    <button
+                        [id]="channel.channelId"
+                        (click)="selectedChannelId = channel.channelId"
+                        class="btn btn-link nav-link"
+                        [class.active]="selectedChannelId === channel.channelId"
+                        [attr.aria-selected]="selectedChannelId === channel.channelId"
+                        type="button"
+                    >
+                        {{ channel.channelCode | channelCodeToLabel | translate }}
+                    </button>
+                </li>
+            </ul>
+            <vdr-permission-grid
+                class="card-span"
+                [activePermissions]="getPermissionsForSelectedChannel()"
+                [permissionDefinitions]="permissionDefinitions"
+                [readonly]="true"
+            ></vdr-permission-grid>
+        </vdr-card>
+    </vdr-page-block>
 </form>

+ 1 - 0
packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.scss

@@ -1,3 +1,4 @@
 ul.nav {
     overflow-x: auto;
+    overflow-y: hidden;
 }

+ 58 - 49
packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.ts

@@ -1,23 +1,24 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
+import { FormBuilder, Validators } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { ResultOf } from '@graphql-typed-document-node/core';
 import {
     Administrator,
-    AdministratorFragment,
-    BaseDetailComponent,
+    ADMINISTRATOR_FRAGMENT,
     CreateAdministratorInput,
-    CustomFieldConfig,
     DataService,
+    GetAdministratorDetailDocument,
     LanguageCode,
     NotificationService,
     Permission,
     PermissionDefinition,
     RoleFragment,
-    ServerConfigService,
+    TypedBaseDetailComponent,
     UpdateAdministratorInput,
 } from '@vendure/admin-ui/core';
 import { CUSTOMER_ROLE_CODE } from '@vendure/common/lib/shared-constants';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { gql } from 'apollo-angular';
 import { Observable } from 'rxjs';
 import { mergeMap, take } from 'rxjs/operators';
 
@@ -27,6 +28,15 @@ export interface PermissionsByChannel {
     permissions: { [K in Permission]: boolean };
 }
 
+export const GET_ADMINISTRATOR_DETAIL = gql`
+    query GetAdministratorDetail($id: ID!) {
+        administrator(id: $id) {
+            ...Administrator
+        }
+    }
+    ${ADMINISTRATOR_FRAGMENT}
+`;
+
 @Component({
     selector: 'vdr-admin-detail',
     templateUrl: './admin-detail.component.html',
@@ -34,15 +44,27 @@ export interface PermissionsByChannel {
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AdminDetailComponent
-    extends BaseDetailComponent<AdministratorFragment>
+    extends TypedBaseDetailComponent<typeof GetAdministratorDetailDocument, 'administrator'>
     implements OnInit, OnDestroy
 {
-    customFields: CustomFieldConfig[];
-    administrator$: Observable<AdministratorFragment>;
+    customFields = this.getCustomFieldConfig('Administrator');
+    detailForm = this.formBuilder.group({
+        emailAddress: ['', Validators.required],
+        firstName: ['', Validators.required],
+        lastName: ['', Validators.required],
+        password: [''],
+        roles: [
+            [] as NonNullable<
+                ResultOf<typeof GetAdministratorDetailDocument>['administrator']
+            >['user']['roles'],
+        ],
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
     permissionDefinitions: PermissionDefinition[];
     allRoles$: Observable<RoleFragment[]>;
     selectedRoles: RoleFragment[] = [];
-    detailForm: UntypedFormGroup;
     selectedRolePermissions: { [channelId: string]: PermissionsByChannel } = {} as any;
     selectedChannelId: string | null = null;
 
@@ -51,31 +73,16 @@ export class AdminDetailComponent
     }
 
     constructor(
-        router: Router,
-        route: ActivatedRoute,
-        serverConfigService: ServerConfigService,
         private changeDetector: ChangeDetectorRef,
         protected dataService: DataService,
-        private formBuilder: UntypedFormBuilder,
+        private formBuilder: FormBuilder,
         private notificationService: NotificationService,
     ) {
-        super(route, router, serverConfigService, dataService);
-        this.customFields = this.getCustomFieldConfig('Administrator');
-        this.detailForm = this.formBuilder.group({
-            emailAddress: ['', Validators.required],
-            firstName: ['', Validators.required],
-            lastName: ['', Validators.required],
-            password: [''],
-            roles: [[]],
-            customFields: this.formBuilder.group(
-                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
-            ),
-        });
+        super();
     }
 
     ngOnInit() {
         this.init();
-        this.administrator$ = this.entity$;
         this.allRoles$ = this.dataService.administrator
             .getRoles(999)
             .mapStream(item => item.roles.items.filter(i => i.code !== CUSTOMER_ROLE_CODE));
@@ -123,14 +130,17 @@ export class AdminDetailComponent
     }
 
     create() {
-        const formValue = this.detailForm.value;
+        const { emailAddress, firstName, lastName, password, customFields, roles } = this.detailForm.value;
+        if (!emailAddress || !firstName || !lastName || !password) {
+            return;
+        }
         const administrator: CreateAdministratorInput = {
-            emailAddress: formValue.emailAddress,
-            firstName: formValue.firstName,
-            lastName: formValue.lastName,
-            password: formValue.password,
-            customFields: formValue.customFields,
-            roleIds: formValue.roles.map(role => role.id),
+            emailAddress,
+            firstName,
+            lastName,
+            password,
+            customFields,
+            roleIds: roles?.map(role => role.id).filter(notNullOrUndefined) ?? [],
         };
         this.dataService.administrator.createAdministrator(administrator).subscribe(
             data => {
@@ -150,7 +160,7 @@ export class AdminDetailComponent
     }
 
     save() {
-        this.administrator$
+        this.entity$
             .pipe(
                 take(1),
                 mergeMap(({ id }) => {
@@ -162,7 +172,7 @@ export class AdminDetailComponent
                         lastName: formValue.lastName,
                         password: formValue.password,
                         customFields: formValue.customFields,
-                        roleIds: formValue.roles.map(role => role.id),
+                        roleIds: formValue.roles?.map(role => role.id),
                     };
                     return this.dataService.administrator.updateAdministrator(administrator);
                 }),
@@ -183,23 +193,22 @@ export class AdminDetailComponent
             );
     }
 
-    protected setFormValues(administrator: Administrator, languageCode: LanguageCode): void {
+    protected setFormValues(
+        entity: NonNullable<ResultOf<typeof GetAdministratorDetailDocument>['administrator']>,
+        languageCode: LanguageCode,
+    ) {
         this.detailForm.patchValue({
-            emailAddress: administrator.emailAddress,
-            firstName: administrator.firstName,
-            lastName: administrator.lastName,
-            roles: administrator.user.roles,
+            emailAddress: entity.emailAddress,
+            firstName: entity.firstName,
+            lastName: entity.lastName,
+            roles: entity.user.roles,
         });
         if (this.customFields.length) {
-            this.setCustomFieldFormValues(
-                this.customFields,
-                this.detailForm.get(['customFields']),
-                administrator,
-            );
+            this.setCustomFieldFormValues(this.customFields, this.detailForm.get(['customFields']), entity);
         }
         const passwordControl = this.detailForm.get('password');
         if (passwordControl) {
-            if (!administrator.id) {
+            if (!entity.id) {
                 passwordControl.setValidators([Validators.required]);
             } else {
                 passwordControl.setValidators([]);
@@ -211,11 +220,11 @@ export class AdminDetailComponent
     private buildPermissionsMap() {
         const permissionsControl = this.detailForm.get('roles');
         if (permissionsControl) {
-            const roles: RoleFragment[] = permissionsControl.value;
+            const roles = permissionsControl.value;
             const channelIdPermissionsMap = new Map<string, Set<Permission>>();
             const channelIdCodeMap = new Map<string, string>();
 
-            for (const role of roles) {
+            for (const role of roles ?? []) {
                 for (const channel of role.channels) {
                     const channelPermissions = channelIdPermissionsMap.get(channel.id);
                     const permissionSet = channelPermissions || new Set<Permission>();

+ 7 - 2
packages/admin-ui/src/lib/settings/src/components/administrator-list/administrator-list-bulk-actions.ts

@@ -1,8 +1,13 @@
-import { createBulkDeleteAction, GetAdministratorsQuery, ItemOf, Permission } from '@vendure/admin-ui/core';
+import {
+    createBulkDeleteAction,
+    GetAdministratorListQuery,
+    ItemOf,
+    Permission,
+} from '@vendure/admin-ui/core';
 import { map } from 'rxjs/operators';
 
 export const deleteAdministratorsBulkAction = createBulkDeleteAction<
-    ItemOf<GetAdministratorsQuery, 'administrators'>
+    ItemOf<GetAdministratorListQuery, 'administrators'>
 >({
     location: 'administrator-list',
     requiresPermission: userPermissions => userPermissions.includes(Permission.DeleteAdministrator),

Некоторые файлы не были показаны из-за большого количества измененных файлов