Kaynağa Gözat

feat(admin-ui): Allow custom components to embed in detail views

Relates to #415
Michael Bromley 4 yıl önce
ebeveyn
işleme
e15c553ad2
32 değiştirilmiş dosya ile 407 ekleme ve 127 silme
  1. 3 36
      docs/content/plugins/extending-the-admin-ui/adding-navigation-items/_index.md
  2. 61 0
      docs/content/plugins/extending-the-admin-ui/custom-detail-components/_index.md
  3. 1 1
      docs/content/plugins/extending-the-admin-ui/custom-form-inputs/_index.md
  4. 5 0
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.html
  5. 20 17
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.html
  6. 5 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  7. 67 0
      packages/admin-ui/src/lib/core/src/common/component-registry-types.ts
  8. 0 38
      packages/admin-ui/src/lib/core/src/common/ui-extension-types.ts
  9. 28 0
      packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component-types.ts
  10. 40 0
      packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component.service.ts
  11. 2 5
      packages/admin-ui/src/lib/core/src/providers/custom-field-component/custom-field-component.service.ts
  12. 7 11
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts
  13. 3 3
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.ts
  14. 7 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  15. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/action-bar-items/action-bar-items.component.ts
  16. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.html
  17. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.scss
  18. 57 0
      packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.ts
  19. 5 0
      packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.html
  20. 4 4
      packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.ts
  21. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  22. 21 8
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html
  23. 7 1
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html
  24. 6 0
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html
  25. 5 0
      packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.html
  26. 17 2
      packages/admin-ui/src/lib/settings/src/components/channel-detail/channel-detail.component.html
  27. 5 0
      packages/admin-ui/src/lib/settings/src/components/country-detail/country-detail.component.html
  28. 5 0
      packages/admin-ui/src/lib/settings/src/components/global-settings/global-settings.component.html
  29. 6 0
      packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.html
  30. 6 0
      packages/admin-ui/src/lib/settings/src/components/shipping-method-detail/shipping-method-detail.component.html
  31. 5 0
      packages/admin-ui/src/lib/settings/src/components/tax-category-detail/tax-category-detail.component.html
  32. 5 0
      packages/admin-ui/src/lib/settings/src/components/tax-rate-detail/tax-rate-detail.component.html

+ 3 - 36
docs/content/plugins/extending-the-admin-ui/adding-navigation-items/_index.md

@@ -7,7 +7,7 @@ weight: 5
 
 ## Extending the NavMenu
 
-Once you have defined some custom routes in a lazy extension module, you need some way for the administrator to access them. For this you will use the `addNavMenuItem` and `addNavMenuSection` functions. 
+Once you have defined some custom routes in a lazy extension module, you need some way for the administrator to access them. For this you will use the [addNavMenuItem]({{< relref "add-nav-menu-item" >}}) and [addNavMenuSection]({{< relref "add-nav-menu-item" >}}) functions. 
 
 Let's add a new section to the Admin UI main nav bar containing a link to the "greeter" module from the [Using Angular]({{< relref "../using-angular" >}}) example:
 
@@ -70,7 +70,7 @@ This is done by setting the `id` property to that of an existing nav menu sectio
 
 ## Adding new ActionBar buttons
 
-It may not always make sense to navigate to your extension view from the main nav menu. For example, a "product reviews" extension that shows reviews for a particular product. In this case, you can add new buttons to the "ActionBar", which is the horizontal section at the top of each screen containing the primary actions for that view.
+It may not always make sense to navigate to your extension view from the main nav menu. For example, a "product reviews" extension that shows reviews for a particular product. In this case, you can add new buttons to the "ActionBar", which is the horizontal section at the top of each screen containing the primary actions for that view. This is done using the [addActionBarItem function]({{< relref "add-action-bar-item" >}}).
 
 Here's an example of how this is done:
 
@@ -99,37 +99,4 @@ export class SharedExtensionModule {}
 
 {{< figure src="./ui-extensions-actionbar.jpg" >}}
 
-In each list or detail view in the app, the ActionBar has a unique `locationId` which is how the app knows in which view to place your button. Here is a complete list of available locations into which you can add new ActionBar buttons:
-
-```text
-asset-list
-collection-detail
-collection-list
-facet-detail
-facet-list
-product-detail
-product-list
-customer-detail
-customer-list
-promotion-detail
-promotion-list
-order-detail
-order-list
-administrator-detail
-administrator-list
-channel-detail
-channel-list
-country-detail
-country-list
-global-settings-detail
-payment-method-detail
-payment-method-list
-role-detail
-role-list
-shipping-method-detail
-shipping-method-list
-tax-category-detail
-tax-category-list
-tax-rate-detail
-tax-rate-list
-```
+In each list or detail view in the app, the ActionBar has a unique `locationId` which is how the app knows in which view to place your button. The complete list of available locations into which you can add new ActionBar can be found in the [ActionBarLocationId docs]({{< relref "action-bar-location-id" >}}).

+ 61 - 0
docs/content/plugins/extending-the-admin-ui/custom-detail-components/_index.md

@@ -0,0 +1,61 @@
+---
+title: 'Custom Detail Components'
+weight: 6
+---
+
+# Custom Detail Components
+
+Most of the detail views can be extended with custom Angular components using the [registerCustomDetailComponent function]({{< relref "register-custom-detail-component" >}}).
+
+Any components registered in this way will appear below the main detail form.
+
+Let's imagine that your project has an external content management system (CMS) which is used to store additional details about products. You might want to display some of this information in the product detail page.
+
+```TypeScript
+import { NgModule, Component, OnInit } from '@angular/core';
+import { switchMap } from 'rxjs';
+import { FormGroup } from '@angular/forms';
+import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
+import {
+    DataService,
+    SharedModule,
+    CustomDetailComponent,
+    registerCustomDetailComponent,
+    GetProductWithVariants
+} from '@vendure/admin-ui/core';
+
+@Component({
+  template: `
+    {{ extraInfo$ | async | json }}
+  `,
+})
+export class ProductInfoComponent implements CustomDetailComponent, OnInit {
+  // These two properties are provided by Vendure and will vary
+  // depending on the particular detail page you are embedding this
+  // component into.
+  entity$: Observable<GetProductWithVariants.Product>
+  detailForm: FormGroup;
+  
+  extraInfo$: Observable<any>;
+  
+  constructor(private cmsDataService: CmsDataService) {}
+  
+  ngOnInit() {
+    this.extraInfo$ = this.entity$.pipe(
+      switchMap(entity => this.cmsDataService.getDataFor(entity.id))
+    );
+  }
+}
+
+@NgModule({
+    imports: [SharedModule],
+    declarations: [ProductInfoComponent],
+    providers: [
+        registerCustomDetailComponent('product-detail', ProductInfoComponent),
+    ]
+})
+export class SharedExtensionModule {}
+```
+
+The valid locations for embedding custom detail components can be found in the [CustomDetailComponentLocationId docs]({{< relref "custom-detail-component-location-id" >}}).
+

+ 1 - 1
docs/content/plugins/extending-the-admin-ui/custom-form-inputs/_index.md

@@ -25,7 +25,7 @@ By default, the "intensity" field will be displayed as a number input:
 
 {{< figure src="./ui-extensions-custom-field-default.jpg" >}}
 
-But let's say we want to display a range slider instead. Here's how we can do this using our shared extension module combined with the `registerFormInputComponent()` function:
+But let's say we want to display a range slider instead. Here's how we can do this using our shared extension module combined with the [registerFormInputComponent function]({{< relref "register-form-input-component" >}}):
 
 ```TypeScript
 import { NgModule, Component } from '@angular/core';

+ 5 - 0
packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.html

@@ -86,6 +86,11 @@
                     [readonly]="!(updatePermission | hasPermission)"
                 ></vdr-tabbed-custom-fields>
             </section>
+            <vdr-custom-detail-component-host
+                locationId="collection-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
         </div>
         <div class="clr-col-md-auto">
             <vdr-product-assets

+ 20 - 17
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.html

@@ -44,7 +44,9 @@
                     id="visibility"
                 />
                 <label class="visible-toggle">
-                    <ng-container *ngIf="detailForm.value.facet.visible; else private">{{ 'catalog.public' | translate }}</ng-container>
+                    <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>
@@ -80,6 +82,11 @@
                 [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)">
@@ -98,11 +105,7 @@
                 </tr>
             </thead>
             <tbody>
-                <tr
-                    class="facet-value"
-                    *ngFor="let value of values; let i = index"
-                    [formGroupName]="i"
-                >
+                <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>
@@ -116,17 +119,17 @@
                     </td>
                     <td class="align-middle"><input type="text" formControlName="code" readonly /></td>
                     <td class="" *ngIf="customValueFields.length">
-<!--                    <ng-container *ngFor="let customField of customValueFields">-->
-<!--                       -->
-<!--                            <vdr-custom-field-control-->
-<!--                                *ngIf="customValueFieldIsSet(i, customField.name)"-->
-<!--                                entityName="FacetValue"-->
-<!--                                [showLabel]="false"-->
-<!--                                [customFieldsFormGroup]="detailForm.get(['values', i, 'customFields'])"-->
-<!--                                [customField]="customField"-->
-<!--                            ></vdr-custom-field-control>-->
-<!--                      -->
-<!--                    </ng-container>-->
+                        <!--                    <ng-container *ngFor="let customField of customValueFields">-->
+                        <!--                       -->
+                        <!--                            <vdr-custom-field-control-->
+                        <!--                                *ngIf="customValueFieldIsSet(i, customField.name)"-->
+                        <!--                                entityName="FacetValue"-->
+                        <!--                                [showLabel]="false"-->
+                        <!--                                [customFieldsFormGroup]="detailForm.get(['values', i, 'customFields'])"-->
+                        <!--                                [customField]="customField"-->
+                        <!--                            ></vdr-custom-field-control>-->
+                        <!--                      -->
+                        <!--                    </ng-container>-->
                         <vdr-tabbed-custom-fields
                             entityName="FacetValue"
                             [customFields]="customFields"

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

@@ -137,6 +137,11 @@
                                     [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">

+ 67 - 0
packages/admin-ui/src/lib/core/src/common/component-registry-types.ts

@@ -41,3 +41,70 @@ export interface FormInputComponent<C = InputComponentConfig> {
 export type InputComponentConfig = {
     [prop: string]: any;
 };
+
+/**
+ * @description
+ * The valid locationIds for registering action bar items.
+ *
+ * @docsCategory action-bar
+ */
+export type ActionBarLocationId =
+    | 'administrator-detail'
+    | 'administrator-list'
+    | 'asset-detail'
+    | 'asset-list'
+    | 'channel-detail'
+    | 'channel-list'
+    | 'collection-detail'
+    | 'collection-list'
+    | 'country-detail'
+    | 'country-list'
+    | 'customer-detail'
+    | 'customer-list'
+    | 'customer-group-list'
+    | 'facet-detail'
+    | 'facet-list'
+    | 'global-setting-detail'
+    | 'system-status'
+    | 'job-list'
+    | 'order-detail'
+    | 'order-list'
+    | 'payment-method-detail'
+    | 'payment-method-list'
+    | 'product-detail'
+    | 'product-list'
+    | 'promotion-detail'
+    | 'promotion-list'
+    | 'role-detail'
+    | 'role-list'
+    | 'shipping-method-detail'
+    | 'shipping-method-list'
+    | 'tax-category-detail'
+    | 'tax-category-list'
+    | 'tax-rate-detail'
+    | 'tax-rate-list'
+    | 'zone-list';
+
+/**
+ * @description
+ * The valid locations for embedding a {@link CustomDetailComponent}.
+ *
+ * @docsCategory custom-detail-components
+ */
+export type CustomDetailComponentLocationId =
+    | 'administrator-detail'
+    | 'channel-detail'
+    | 'collection-detail'
+    | 'country-detail'
+    | 'customer-detail'
+    | 'facet-detail'
+    | 'global-settings-detail'
+    | 'order-detail'
+    | 'payment-method-detail'
+    | 'product-detail'
+    | 'promotion-detail'
+    | 'shipping-method-detail'
+    | 'tax-category-detail'
+    | 'tax-rate-detail';
+
+export type UIExtensionLocationId = ActionBarLocationId | CustomDetailComponentLocationId;

+ 0 - 38
packages/admin-ui/src/lib/core/src/common/ui-extension-types.ts

@@ -1,38 +0,0 @@
-export type ActionBarLocationId =
-    | 'administrator-detail'
-    | 'administrator-list'
-    | 'asset-detail'
-    | 'asset-list'
-    | 'channel-detail'
-    | 'channel-list'
-    | 'collection-detail'
-    | 'collection-list'
-    | 'country-detail'
-    | 'country-list'
-    | 'customer-detail'
-    | 'customer-list'
-    | 'customer-group-list'
-    | 'facet-detail'
-    | 'facet-list'
-    | 'global-setting-detail'
-    | 'system-status'
-    | 'job-list'
-    | 'order-detail'
-    | 'order-list'
-    | 'payment-method-detail'
-    | 'payment-method-list'
-    | 'product-detail'
-    | 'product-list'
-    | 'promotion-detail'
-    | 'promotion-list'
-    | 'role-detail'
-    | 'role-list'
-    | 'shipping-method-detail'
-    | 'shipping-method-list'
-    | 'tax-category-detail'
-    | 'tax-category-list'
-    | 'tax-rate-detail'
-    | 'tax-rate-list'
-    | 'zone-list';
-
-export type UIExtensionLocationId = ActionBarLocationId;

+ 28 - 0
packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component-types.ts

@@ -0,0 +1,28 @@
+import { Type } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+import { Observable } from 'rxjs';
+
+import { CustomDetailComponentLocationId } from '../../common/component-registry-types';
+
+/**
+ * @description
+ * CustomDetailComponents allow any arbitrary Angular components to be embedded in entity detail
+ * pages of the Admin UI.
+ *
+ * @docsCategory custom-detail-components
+ */
+export interface CustomDetailComponent {
+    entity$: Observable<any>;
+    detailForm: FormGroup;
+}
+
+/**
+ * @description
+ * Configures a {@link CustomDetailComponent} to be placed in the given location.
+ *
+ * @docsCategory custom-detail-components
+ */
+export interface CustomDetailComponentConfig {
+    locationId: CustomDetailComponentLocationId;
+    component: Type<CustomDetailComponent>;
+}

+ 40 - 0
packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component.service.ts

@@ -0,0 +1,40 @@
+import { APP_INITIALIZER, Injectable, Provider } from '@angular/core';
+
+import { CustomDetailComponentConfig } from './custom-detail-component-types';
+
+/**
+ * @description
+ * Registers a {@link CustomDetailComponent} to be placed in a given location. This allows you
+ * to embed any type of custom Angular component in the entity detail pages of the Admin UI.
+ *
+ * @docsCategory custom-detail-components
+ */
+export function registerCustomDetailComponent(config: CustomDetailComponentConfig): Provider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (customDetailComponentService: CustomDetailComponentService) => () => {
+            customDetailComponentService.registerCustomDetailComponent(config);
+        },
+        deps: [CustomDetailComponentService],
+    };
+}
+
+@Injectable({
+    providedIn: 'root',
+})
+export class CustomDetailComponentService {
+    private customDetailComponents = new Map<string, CustomDetailComponentConfig[]>();
+
+    registerCustomDetailComponent(config: CustomDetailComponentConfig) {
+        if (this.customDetailComponents.has(config.locationId)) {
+            this.customDetailComponents.get(config.locationId)?.push(config);
+        } else {
+            this.customDetailComponents.set(config.locationId, [config]);
+        }
+    }
+
+    getCustomDetailComponentsFor(locationId: string): CustomDetailComponentConfig[] {
+        return this.customDetailComponents.get(locationId) ?? [];
+    }
+}

+ 2 - 5
packages/admin-ui/src/lib/core/src/providers/custom-field-component/custom-field-component.service.ts

@@ -1,4 +1,4 @@
-import { ComponentFactoryResolver, Injectable } from '@angular/core';
+import { Injectable } from '@angular/core';
 import { Type } from '@vendure/common/lib/shared-types';
 
 import { FormInputComponent } from '../../common/component-registry-types';
@@ -20,10 +20,7 @@ export type CustomFieldEntityName = Exclude<keyof CustomFields, '__typename'>;
     providedIn: 'root',
 })
 export class CustomFieldComponentService {
-    constructor(
-        private componentFactoryResolver: ComponentFactoryResolver,
-        private componentRegistryService: ComponentRegistryService,
-    ) {}
+    constructor(private componentRegistryService: ComponentRegistryService) {}
 
     /**
      * Register a CustomFieldControl component to be used with the specified customField and entity.

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

@@ -1,7 +1,7 @@
 import { ActivatedRoute } from '@angular/router';
 import { Observable } from 'rxjs';
 
-import { UIExtensionLocationId } from '../../common/ui-extension-types';
+import { ActionBarLocationId, UIExtensionLocationId } from '../../common/component-registry-types';
 import { DataService } from '../../data/providers/data.service';
 import { NotificationService } from '../notification/notification.service';
 
@@ -12,7 +12,7 @@ export type NavMenuBadgeType = 'none' | 'info' | 'success' | 'warning' | 'error'
  * A color-coded notification badge which will be displayed by the
  * NavMenuItem's icon.
  *
- * @docsCategory navigation
+ * @docsCategory nav-menu
  * @docsPage navigation-types
  */
 export interface NavMenuBadge {
@@ -31,8 +31,7 @@ export interface NavMenuBadge {
  * A NavMenuItem is a menu item in the main (left-hand side) nav
  * bar.
  *
- * @docsCategory navigation
- * @docsPage navigation-types
+ * @docsCategory nav-menu
  */
 export interface NavMenuItem {
     id: string;
@@ -52,8 +51,7 @@ export interface NavMenuItem {
  * A NavMenuSection is a grouping of links in the main
  * (left-hand side) nav bar.
  *
- * @docsCategory navigation
- * @docsPage navigation-types
+ * @docsCategory nav-menu
  */
 export interface NavMenuSection {
     id: string;
@@ -71,8 +69,7 @@ export interface NavMenuSection {
  * @description
  * Utilities available to the onClick handler of an ActionBarItem.
  *
- * @docsCategory navigation
- * @docsPage navigation-types
+ * @docsCategory action-bar
  */
 export interface OnClickContext {
     route: ActivatedRoute;
@@ -84,13 +81,12 @@ export interface OnClickContext {
  * @description
  * A button in the ActionBar area at the top of one of the list or detail views.
  *
- * @docsCategory navigation
- * @docsPage navigation-types
+ * @docsCategory action-bar
  */
 export interface ActionBarItem {
     id: string;
     label: string;
-    locationId: UIExtensionLocationId;
+    locationId: ActionBarLocationId;
     disabled?: Observable<boolean>;
     onClick?: (event: MouseEvent, context: OnClickContext) => void;
     routerLink?: RouterLinkDefinition;

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

@@ -39,7 +39,7 @@ import {
  * })
  * export class MyUiExtensionModule {}
  * ```
- * @docsCategory navigation
+ * @docsCategory nav-menu
  */
 export function addNavMenuSection(config: NavMenuSection, before?: string): Provider {
     return {
@@ -79,7 +79,7 @@ export function addNavMenuSection(config: NavMenuSection, before?: string): Prov
  * export class MyUiExtensionModule {}
  * ``
  *
- * @docsCategory navigation
+ * @docsCategory nav-menu
  */
 export function addNavMenuItem(config: NavMenuItem, sectionId: string, before?: string): Provider {
     return {
@@ -115,7 +115,7 @@ export function addNavMenuItem(config: NavMenuItem, sectionId: string, before?:
  * })
  * export class MyUiExtensionModule {}
  * ```
- * @docsCategory navigation
+ * @docsCategory action-bar
  */
 export function addActionBarItem(config: ActionBarItem): Provider {
     return {

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

@@ -70,6 +70,8 @@ export * from './data/utils/remove-readonly-custom-fields';
 export * from './data/utils/transform-relation-custom-field-inputs';
 export * from './providers/auth/auth.service';
 export * from './providers/component-registry/component-registry.service';
+export * from './providers/custom-detail-component/custom-detail-component-types';
+export * from './providers/custom-detail-component/custom-detail-component.service';
 export * from './providers/custom-field-component/custom-field-component.service';
 export * from './providers/dashboard-widget/dashboard-widget-types';
 export * from './providers/dashboard-widget/dashboard-widget.service';
@@ -102,6 +104,7 @@ export * from './shared/components/channel-badge/channel-badge.component';
 export * from './shared/components/chip/chip.component';
 export * from './shared/components/configurable-input/configurable-input.component';
 export * from './shared/components/currency-input/currency-input.component';
+export * from './shared/components/custom-detail-component-host/custom-detail-component-host.component';
 export * from './shared/components/custom-field-control/custom-field-control.component';
 export * from './shared/components/customer-label/customer-label.component';
 export * from './shared/components/data-table/data-table-column.component';
@@ -158,16 +161,19 @@ export * from './shared/components/rich-text-editor/rich-text-editor.component';
 export * from './shared/components/select-toggle/select-toggle.component';
 export * from './shared/components/simple-dialog/simple-dialog.component';
 export * from './shared/components/status-badge/status-badge.component';
+export * from './shared/components/tabbed-custom-fields/tabbed-custom-fields.component';
 export * from './shared/components/table-row-action/table-row-action.component';
 export * from './shared/components/tag-selector/tag-selector.component';
 export * from './shared/components/timeline-entry/timeline-entry.component';
 export * from './shared/components/title-input/title-input.component';
+export * from './shared/components/ui-extension-point/ui-extension-point.component';
 export * from './shared/directives/disabled.directive';
 export * from './shared/directives/if-default-channel-active.directive';
 export * from './shared/directives/if-directive-base';
 export * from './shared/directives/if-multichannel.directive';
 export * from './shared/directives/if-permissions.directive';
 export * from './shared/dynamic-form-inputs/boolean-form-input/boolean-form-input.component';
+export * from './shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component';
 export * from './shared/dynamic-form-inputs/currency-form-input/currency-form-input.component';
 export * from './shared/dynamic-form-inputs/customer-group-form-input/customer-group-form-input.component';
 export * from './shared/dynamic-form-inputs/date-form-input/date-form-input.component';
@@ -184,6 +190,7 @@ export * from './shared/dynamic-form-inputs/relation-form-input/product-variant/
 export * from './shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component';
 export * from './shared/dynamic-form-inputs/relation-form-input/relation-form-input.component';
 export * from './shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component';
+export * from './shared/dynamic-form-inputs/rich-text-form-input/rich-text-form-input.component';
 export * from './shared/dynamic-form-inputs/select-form-input/select-form-input.component';
 export * from './shared/dynamic-form-inputs/text-form-input/text-form-input.component';
 export * from './shared/dynamic-form-inputs/textarea-form-input/textarea-form-input.component';

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/action-bar-items/action-bar-items.component.ts

@@ -12,7 +12,7 @@ import { assertNever } from '@vendure/common/lib/shared-utils';
 import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
 import { filter, map } from 'rxjs/operators';
 
-import { ActionBarLocationId } from '../../../common/ui-extension-types';
+import { ActionBarLocationId } from '../../../common/component-registry-types';
 import { DataService } from '../../../data/providers/data.service';
 import { ActionBarItem } from '../../../providers/nav-builder/nav-builder-types';
 import { NavBuilderService } from '../../../providers/nav-builder/nav-builder.service';

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.html

@@ -0,0 +1 @@
+<vdr-ui-extension-point [locationId]="locationId" api="detailComponent"></vdr-ui-extension-point>

+ 0 - 0
packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.scss


+ 57 - 0
packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.ts

@@ -0,0 +1,57 @@
+import {
+    ChangeDetectionStrategy,
+    Component,
+    ComponentFactoryResolver,
+    ComponentRef,
+    Injector,
+    Input,
+    OnDestroy,
+    OnInit,
+    ViewContainerRef,
+} from '@angular/core';
+import { FormGroup } from '@angular/forms';
+import { Observable } from 'rxjs';
+
+import { CustomDetailComponentLocationId } from '../../../common/component-registry-types';
+import { CustomDetailComponent } from '../../../providers/custom-detail-component/custom-detail-component-types';
+import { CustomDetailComponentService } from '../../../providers/custom-detail-component/custom-detail-component.service';
+
+@Component({
+    selector: 'vdr-custom-detail-component-host',
+    templateUrl: './custom-detail-component-host.component.html',
+    styleUrls: ['./custom-detail-component-host.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CustomDetailComponentHostComponent implements OnInit, OnDestroy {
+    @Input() locationId: CustomDetailComponentLocationId;
+    @Input() entity$: Observable<any>;
+    @Input() detailForm: FormGroup;
+
+    private componentRefs: Array<ComponentRef<CustomDetailComponent>> = [];
+
+    constructor(
+        private viewContainerRef: ViewContainerRef,
+        private componentFactoryResolver: ComponentFactoryResolver,
+        private customDetailComponentService: CustomDetailComponentService,
+    ) {}
+
+    ngOnInit(): void {
+        const customComponents = this.customDetailComponentService.getCustomDetailComponentsFor(
+            this.locationId,
+        );
+
+        for (const config of customComponents) {
+            const factory = this.componentFactoryResolver.resolveComponentFactory(config.component);
+            const componentRef = this.viewContainerRef.createComponent(factory);
+            componentRef.instance.entity$ = this.entity$;
+            componentRef.instance.detailForm = this.detailForm;
+            this.componentRefs.push(componentRef);
+        }
+    }
+
+    ngOnDestroy() {
+        for (const ref of this.componentRefs) {
+            ref.destroy();
+        }
+    }
+}

+ 5 - 0
packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.html

@@ -22,6 +22,11 @@ addNavMenuItem({{ '{' }}
   {{ '}' }},
   '{{ locationId }}'
 )</pre>
+                <pre *ngIf="api === 'detailComponent'">
+registerCustomDetailComponent({{ '{' }}
+  locationId: '{{ locationId }}',
+  component: MyCustomComponent,
+{{ '}' }})</pre>
             </div>
         </vdr-dropdown-menu>
     </vdr-dropdown>

+ 4 - 4
packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.ts

@@ -1,8 +1,8 @@
 import { ChangeDetectionStrategy, Component, Input, isDevMode, OnInit } from '@angular/core';
-import { DataService } from '@vendure/admin-ui/core';
 import { Observable } from 'rxjs';
 
-import { UIExtensionLocationId } from '../../../common/ui-extension-types';
+import { UIExtensionLocationId } from '../../../common/component-registry-types';
+import { DataService } from '../../../data/providers/data.service';
 
 @Component({
     selector: 'vdr-ui-extension-point',
@@ -11,10 +11,10 @@ import { UIExtensionLocationId } from '../../../common/ui-extension-types';
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class UiExtensionPointComponent implements OnInit {
-    @Input() locationId: UIExtensionLocationId | string;
+    @Input() locationId: UIExtensionLocationId;
     @Input() topPx: number;
     @Input() leftPx: number;
-    @Input() api: 'actionBar' | 'navMenu';
+    @Input() api: 'actionBar' | 'navMenu' | 'detailComponent';
     display$: Observable<boolean>;
     readonly isDevMode = isDevMode();
     constructor(private dataService: DataService) {}

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

@@ -34,6 +34,7 @@ import { ChannelBadgeComponent } from './components/channel-badge/channel-badge.
 import { ChipComponent } from './components/chip/chip.component';
 import { ConfigurableInputComponent } from './components/configurable-input/configurable-input.component';
 import { CurrencyInputComponent } from './components/currency-input/currency-input.component';
+import { CustomDetailComponentHostComponent } from './components/custom-detail-component-host/custom-detail-component-host.component';
 import { CustomFieldControlComponent } from './components/custom-field-control/custom-field-control.component';
 import { CustomerLabelComponent } from './components/customer-label/customer-label.component';
 import { DataTableColumnComponent } from './components/data-table/data-table-column.component';
@@ -227,6 +228,7 @@ const DECLARATIONS = [
     StatusBadgeComponent,
     TabbedCustomFieldsComponent,
     UiExtensionPointComponent,
+    CustomDetailComponentHostComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [

+ 21 - 8
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html

@@ -3,7 +3,11 @@
         <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'">
+            <div
+                class="last-login"
+                *ngIf="(entity$ | async)?.user?.lastLogin as lastLogin"
+                [title]="lastLogin | localeDate: 'medium'"
+            >
                 {{ 'customer.last-login' | translate }}: {{ lastLogin | timeAgo }}
             </div>
         </div>
@@ -58,12 +62,12 @@
         <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>
+        [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>
@@ -76,6 +80,11 @@
             [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">
@@ -93,7 +102,11 @@
         {{ 'customer.not-a-member-of-any-groups' | translate }}
     </ng-template>
     <div>
-        <button class="btn btn-sm btn-secondary" (click)="addToGroup()" *vdrIfPermissions="'UpdateCustomerGroup'">
+        <button
+            class="btn btn-sm btn-secondary"
+            (click)="addToGroup()"
+            *vdrIfPermissions="'UpdateCustomerGroup'"
+        >
             <clr-icon shape="plus"></clr-icon>
             {{ 'customer.add-customer-to-group' | translate }}
         </button>

+ 7 - 1
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html

@@ -3,7 +3,7 @@
         <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'])"/>
+                <input type="checkbox" clrToggle name="enabled" [formControl]="detailForm.get(['enabled'])" />
                 <label>{{ 'common.enabled' | translate }}</label>
             </clr-toggle-wrapper>
         </div>
@@ -75,6 +75,12 @@
         ></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>

+ 6 - 0
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html

@@ -118,6 +118,12 @@
                 </tbody>
             </table>
 
+            <vdr-custom-detail-component-host
+                locationId="order-detail"
+                [entity$]="entity$"
+                [detailForm]="detailForm"
+            ></vdr-custom-detail-component-host>
+
             <vdr-order-history
                 [order]="order"
                 [history]="history$ | async"

+ 5 - 0
packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.html

@@ -70,6 +70,11 @@
             [readonly]="!('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"

+ 17 - 2
packages/admin-ui/src/lib/settings/src/components/channel-detail/channel-detail.component.html

@@ -28,10 +28,20 @@
 
 <form class="form" [formGroup]="detailForm">
     <vdr-form-field [label]="'common.code' | translate" for="code">
-        <input id="code" type="text" [readonly]="!(updatePermission | hasPermission)" formControlName="code"/>
+        <input
+            id="code"
+            type="text"
+            [readonly]="!(updatePermission | hasPermission)"
+            formControlName="code"
+        />
     </vdr-form-field>
     <vdr-form-field [label]="'settings.channel-token' | translate" for="token">
-        <input id="token" type="text" [readonly]="!(updatePermission | hasPermission)" formControlName="token"/>
+        <input
+            id="token"
+            type="text"
+            [readonly]="!(updatePermission | hasPermission)"
+            formControlName="token"
+        />
     </vdr-form-field>
     <vdr-form-field [label]="'settings.currency' | translate" for="defaultTaxZoneId">
         <select
@@ -121,4 +131,9 @@
             [readonly]="!(updatePermission | hasPermission)"
         ></vdr-tabbed-custom-fields>
     </section>
+    <vdr-custom-detail-component-host
+        locationId="channel-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
 </form>

+ 5 - 0
packages/admin-ui/src/lib/settings/src/components/country-detail/country-detail.component.html

@@ -68,4 +68,9 @@
             [readonly]="!(updatePermission | hasPermission)"
         ></vdr-tabbed-custom-fields>
     </section>
+    <vdr-custom-detail-component-host
+        locationId="country-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
 </form>

+ 5 - 0
packages/admin-ui/src/lib/settings/src/components/global-settings/global-settings.component.html

@@ -69,4 +69,9 @@
             [readonly]="!(updatePermission | hasPermission)"
         ></vdr-tabbed-custom-fields>
     </section>
+    <vdr-custom-detail-component-host
+        locationId="global-settings-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
 </form>

+ 6 - 0
packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.html

@@ -74,6 +74,12 @@
         ></vdr-tabbed-custom-fields>
     </section>
 
+    <vdr-custom-detail-component-host
+        locationId="payment-method-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
+
     <div class="clr-row mt4">
         <div class="clr-col">
             <label class="clr-control-label">{{ 'settings.payment-eligibility-checker' | translate }}</label>

+ 6 - 0
packages/admin-ui/src/lib/settings/src/components/shipping-method-detail/shipping-method-detail.component.html

@@ -83,6 +83,12 @@
         ></vdr-tabbed-custom-fields>
     </section>
 
+    <vdr-custom-detail-component-host
+        locationId="shipping-method-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
+
     <div class="clr-row mt4">
         <div class="clr-col">
             <label class="clr-control-label">{{ 'settings.shipping-eligibility-checker' | translate }}</label>

+ 5 - 0
packages/admin-ui/src/lib/settings/src/components/tax-category-detail/tax-category-detail.component.html

@@ -55,4 +55,9 @@
             [readonly]="!(updatePermission | hasPermission)"
         ></vdr-tabbed-custom-fields>
     </section>
+    <vdr-custom-detail-component-host
+        locationId="tax-category-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
 </form>

+ 5 - 0
packages/admin-ui/src/lib/settings/src/components/tax-rate-detail/tax-rate-detail.component.html

@@ -88,4 +88,9 @@
             [readonly]="!(updatePermission | hasPermission)"
         ></vdr-tabbed-custom-fields>
     </section>
+    <vdr-custom-detail-component-host
+        locationId="tax-rate-detail"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
 </form>