Browse Source

feat(admin-ui): Allow custom components for Order history timeline

Relates to #1694, relates to #432
Michael Bromley 3 years ago
parent
commit
fc7bcf1e2e

+ 86 - 0
packages/admin-ui/src/lib/core/src/providers/custom-history-entry-component/history-entry-component-types.ts

@@ -0,0 +1,86 @@
+import { Type } from '@angular/core';
+
+import { CustomerFragment, GetOrderHistoryQuery, OrderDetailFragment } from '../../common/generated-types';
+import { TimelineDisplayType } from '../../shared/components/timeline-entry/timeline-entry.component';
+
+export type TimelineHistoryEntry = NonNullable<GetOrderHistoryQuery['order']>['history']['items'][number];
+/**
+ * @description
+ * This interface should be implemented by components intended to display a history entry in the
+ * Order or Customer history timeline. If the component needs access to the Order or Customer object itself,
+ * you should implement {@link OrderHistoryEntryComponent} or {@link CustomerHistoryEntryComponent} respectively.
+ *
+ * @since 1.9.0
+ * @docsCategory custom-history-entry-components
+ */
+export interface HistoryEntryComponent {
+    /**
+     * @description
+     * The HistoryEntry item itself.
+     */
+    entry: TimelineHistoryEntry;
+    /**
+     * @description
+     * Defines whether this entry is highlighted with a "success", "error" etc. color.
+     */
+    getDisplayType: (entry: TimelineHistoryEntry) => TimelineDisplayType;
+    /**
+     * @description
+     * Returns the name of the entry, as displayed in the timeline next to the date. Can be
+     * a translation token which will get passed through the `translate` pipe.
+     */
+    getName: (entry: TimelineHistoryEntry) => string;
+    /**
+     * @description
+     * Featured entries are always expanded. Non-featured entries start of collapsed and can be clicked
+     * to expand.
+     */
+    isFeatured: (entry: TimelineHistoryEntry) => boolean;
+    /**
+     * @description
+     * Optional Clarity icon shape to display with the entry. Examples: `'note'`, `['success-standard', 'is-solid']`
+     */
+    getIconShape?: (entry: TimelineHistoryEntry) => string | readonly [string, string] | undefined;
+}
+
+/**
+ * @description
+ * Used to implement a {@link HistoryEntryComponent} which requires access to the Order object.
+ *
+ * @since 1.9.0
+ * @docsCategory custom-history-entry-components
+ */
+export interface OrderHistoryEntryComponent extends HistoryEntryComponent {
+    order: OrderDetailFragment;
+}
+
+/**
+ * @description
+ * Used to implement a {@link HistoryEntryComponent} which requires access to the Customer object.
+ *
+ * @since 1.9.0
+ * @docsCategory custom-history-entry-components
+ */
+export interface CustomerHistoryEntryComponent extends HistoryEntryComponent {
+    customer: CustomerFragment;
+}
+
+/**
+ * @description
+ * Configuration for registering a custom {@link HistoryEntryComponent}.
+ *
+ * @since 1.9.0
+ * @docsCategory custom-history-entry-components
+ */
+export interface HistoryEntryConfig {
+    /**
+     * @description
+     * The type should correspond to the custom HistoryEntryType string.
+     */
+    type: string;
+    /**
+     * @description
+     * The component to be rendered for this history entry type.
+     */
+    component: Type<HistoryEntryComponent>;
+}

+ 37 - 0
packages/admin-ui/src/lib/core/src/providers/custom-history-entry-component/history-entry-component.service.ts

@@ -0,0 +1,37 @@
+import { APP_INITIALIZER, Injectable, Provider, Type } from '@angular/core';
+
+import { HistoryEntryComponent, HistoryEntryConfig } from './history-entry-component-types';
+
+/**
+ * @description
+ * Registers a {@link HistoryEntryComponent} for displaying history entries in the Order/Customer
+ * history timeline.
+ *
+ * @since 1.9.0
+ * @docsCategory custom-history-entry-components
+ */
+export function registerHistoryEntryComponent(config: HistoryEntryConfig): Provider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (customHistoryEntryComponentService: HistoryEntryComponentService) => () => {
+            customHistoryEntryComponentService.registerComponent(config);
+        },
+        deps: [HistoryEntryComponentService],
+    };
+}
+
+@Injectable({
+    providedIn: 'root',
+})
+export class HistoryEntryComponentService {
+    private customEntryComponents = new Map<string, HistoryEntryConfig>();
+
+    registerComponent(config: HistoryEntryConfig) {
+        this.customEntryComponents.set(config.type, config);
+    }
+
+    getComponent(type: string): Type<HistoryEntryComponent> | undefined {
+        return this.customEntryComponents.get(type)?.component;
+    }
+}

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

@@ -77,6 +77,8 @@ 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/custom-history-entry-component/history-entry-component-types';
+export * from './providers/custom-history-entry-component/history-entry-component.service';
 export * from './providers/dashboard-widget/dashboard-widget-types';
 export * from './providers/dashboard-widget/dashboard-widget.service';
 export * from './providers/dashboard-widget/register-dashboard-widget';

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/timeline-entry/timeline-entry.component.html

@@ -21,7 +21,7 @@
                 {{ createdAt | localeDate: 'short' }}
             </div>
             <div class="name">
-                {{ name }}
+                {{ name | translate }}
             </div>
         </div>
         <div [class.featured-entry]="featured">

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

@@ -17,7 +17,7 @@ import {
     GetCustomer,
     GetCustomerHistory,
     GetCustomerQuery,
-    HistoryEntry,
+    TimelineHistoryEntry,
     ModalService,
     NotificationService,
     ServerConfigService,
@@ -404,7 +404,7 @@ export class CustomerDetailComponent
         });
     }
 
-    updateNote(entry: HistoryEntry) {
+    updateNote(entry: TimelineHistoryEntry) {
         this.modalService
             .fromComponent(EditNoteDialogComponent, {
                 closable: true,
@@ -433,7 +433,7 @@ export class CustomerDetailComponent
             });
     }
 
-    deleteNote(entry: HistoryEntry) {
+    deleteNote(entry: TimelineHistoryEntry) {
         return this.modalService
             .dialog({
                 title: _('common.confirm-delete-note'),

+ 3 - 3
packages/admin-ui/src/lib/customer/src/components/customer-history/customer-history.component.ts

@@ -2,9 +2,9 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
 import {
     Customer,
     GetCustomerHistory,
-    HistoryEntry,
     HistoryEntryType,
     TimelineDisplayType,
+    TimelineHistoryEntry,
 } from '@vendure/admin-ui/core';
 
 @Component({
@@ -17,8 +17,8 @@ export class CustomerHistoryComponent {
     @Input() customer: Customer.Fragment;
     @Input() history: GetCustomerHistory.Items[];
     @Output() addNote = new EventEmitter<{ note: string }>();
-    @Output() updateNote = new EventEmitter<HistoryEntry>();
-    @Output() deleteNote = new EventEmitter<HistoryEntry>();
+    @Output() updateNote = new EventEmitter<TimelineHistoryEntry>();
+    @Output() deleteNote = new EventEmitter<TimelineHistoryEntry>();
     note = '';
     readonly type = HistoryEntryType;
 

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

@@ -12,7 +12,6 @@ import {
     FulfillmentLineSummary,
     GetOrderHistory,
     GetOrderQuery,
-    HistoryEntry,
     HistoryEntryType,
     ModalService,
     NotificationService,
@@ -24,6 +23,7 @@ import {
     RefundOrder,
     ServerConfigService,
     SortOrder,
+    TimelineHistoryEntry,
 } from '@vendure/admin-ui/core';
 import { pick } from '@vendure/common/lib/pick';
 import { assertNever, summate } from '@vendure/common/lib/shared-utils';
@@ -468,7 +468,7 @@ export class OrderDetailComponent
             });
     }
 
-    updateNote(entry: HistoryEntry) {
+    updateNote(entry: TimelineHistoryEntry) {
         this.modalService
             .fromComponent(EditNoteDialogComponent, {
                 closable: true,
@@ -499,7 +499,7 @@ export class OrderDetailComponent
             });
     }
 
-    deleteNote(entry: HistoryEntry) {
+    deleteNote(entry: TimelineHistoryEntry) {
         return this.modalService
             .dialog({
                 title: _('common.confirm-delete-note'),

+ 66 - 0
packages/admin-ui/src/lib/order/src/components/order-history/order-history-entry-host.component.ts

@@ -0,0 +1,66 @@
+import {
+    Component,
+    ComponentFactoryResolver,
+    ComponentRef,
+    EventEmitter,
+    Input,
+    OnDestroy,
+    OnInit,
+    Output,
+    Type,
+    ViewChild,
+    ViewContainerRef,
+} from '@angular/core';
+import {
+    HistoryEntryComponentService,
+    OrderDetailFragment,
+    OrderHistoryEntryComponent,
+    TimelineHistoryEntry,
+} from '@vendure/admin-ui/core';
+
+@Component({
+    selector: 'vdr-order-history-entry-host',
+    template: ` <vdr-timeline-entry
+        [displayType]="instance.getDisplayType(entry)"
+        [iconShape]="instance.getIconShape && instance.getIconShape(entry)"
+        [createdAt]="entry.createdAt"
+        [name]="instance.getName(entry)"
+        [featured]="instance.isFeatured(entry)"
+        [collapsed]="!expanded && !instance.isFeatured(entry)"
+        (expandClick)="expandClick.emit()"
+    >
+        <div #portal></div>
+    </vdr-timeline-entry>`,
+    exportAs: 'historyEntry',
+})
+export class OrderHistoryEntryHostComponent implements OnInit, OnDestroy {
+    @Input() entry: TimelineHistoryEntry;
+    @Input() order: OrderDetailFragment;
+    @Input() expanded: boolean;
+    @Output() expandClick = new EventEmitter<void>();
+    @ViewChild('portal', { static: true, read: ViewContainerRef }) portalRef: ViewContainerRef;
+    instance: OrderHistoryEntryComponent;
+    private componentRef: ComponentRef<OrderHistoryEntryComponent>;
+
+    constructor(
+        private componentFactoryResolver: ComponentFactoryResolver,
+        private historyEntryComponentService: HistoryEntryComponentService,
+    ) {}
+
+    ngOnInit(): void {
+        const componentType = this.historyEntryComponentService.getComponent(
+            this.entry.type,
+        ) as Type<OrderHistoryEntryComponent>;
+
+        const factory = this.componentFactoryResolver.resolveComponentFactory(componentType);
+        const componentRef = this.portalRef.createComponent(factory);
+        componentRef.instance.entry = this.entry;
+        componentRef.instance.order = this.order;
+        this.instance = componentRef.instance;
+        this.componentRef = componentRef;
+    }
+
+    ngOnDestroy() {
+        this.componentRef?.destroy();
+    }
+}

+ 206 - 189
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html

@@ -20,196 +20,213 @@
             </span>
         </div>
     </vdr-timeline-entry>
-    <vdr-timeline-entry
-        *ngFor="let entry of history"
-        [displayType]="getDisplayType(entry)"
-        [iconShape]="getTimelineIcon(entry)"
-        [createdAt]="entry.createdAt"
-        [name]="getName(entry)"
-        [featured]="isFeatured(entry)"
-        [collapsed]="!expanded && !isFeatured(entry)"
-        (expandClick)="expanded = !expanded"
-    >
-        <ng-container [ngSwitch]="entry.type">
-            <ng-container *ngSwitchCase="type.ORDER_STATE_TRANSITION">
-                <div class="title" *ngIf="entry.data.to === 'Delivered'">
-                    {{ 'order.history-order-fulfilled' | translate }}
-                </div>
-                <div class="title" *ngIf="entry.data.to === 'Cancelled'">
-                    {{ 'order.history-order-cancelled' | translate }}
-                </div>
-                <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Delivered'">
-                    {{
-                        'order.history-order-transition'
-                            | translate: { from: entry.data.from, to: entry.data.to }
-                    }}
-                </ng-template>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_MODIFIED">
-                <div class="title">
-                    {{ 'order.history-order-modified' | translate }}
-                </div>
-                <ng-container *ngIf="getModification(entry.data.modificationId) as modification">
-                    {{ 'order.modify-order-price-difference' | translate }}:
-                    <strong>{{ modification.priceChange | localeCurrency: order.currencyCode }}</strong>
-                    <vdr-chip colorType="success" *ngIf="modification.isSettled">{{
-                        'order.modification-settled' | translate
-                    }}</vdr-chip>
-                    <vdr-chip colorType="error" *ngIf="!modification.isSettled">{{
-                        'order.modification-not-settled' | translate
-                    }}</vdr-chip>
-                    <vdr-history-entry-detail>
-                        <vdr-modification-detail
-                            [order]="order"
-                            [modification]="modification"
-                        ></vdr-modification-detail>
-                    </vdr-history-entry-detail>
+    <ng-container *ngFor="let entry of history">
+        <vdr-order-history-entry-host
+            *ngIf="hasCustomComponent(entry.type); else defaultComponents"
+            [order]="order"
+            [entry]="entry"
+            [expanded]="expanded"
+            (expandClick)="expanded = !expanded"
+        ></vdr-order-history-entry-host>
+        <ng-template #defaultComponents>
+            <vdr-timeline-entry
+                [displayType]="getDisplayType(entry)"
+                [iconShape]="getTimelineIcon(entry)"
+                [createdAt]="entry.createdAt"
+                [name]="getName(entry)"
+                [featured]="isFeatured(entry)"
+                [collapsed]="!expanded && !isFeatured(entry)"
+                (expandClick)="expanded = !expanded"
+            >
+                <ng-container [ngSwitch]="entry.type">
+                    <ng-container *ngSwitchCase="type.ORDER_STATE_TRANSITION">
+                        <div class="title" *ngIf="entry.data.to === 'Delivered'">
+                            {{ 'order.history-order-fulfilled' | translate }}
+                        </div>
+                        <div class="title" *ngIf="entry.data.to === 'Cancelled'">
+                            {{ 'order.history-order-cancelled' | translate }}
+                        </div>
+                        <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Delivered'">
+                            {{
+                                'order.history-order-transition'
+                                    | translate: { from: entry.data.from, to: entry.data.to }
+                            }}
+                        </ng-template>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_MODIFIED">
+                        <div class="title">
+                            {{ 'order.history-order-modified' | translate }}
+                        </div>
+                        <ng-container *ngIf="getModification(entry.data.modificationId) as modification">
+                            {{ 'order.modify-order-price-difference' | translate }}:
+                            <strong>{{
+                                modification.priceChange | localeCurrency: order.currencyCode
+                            }}</strong>
+                            <vdr-chip colorType="success" *ngIf="modification.isSettled">{{
+                                'order.modification-settled' | translate
+                            }}</vdr-chip>
+                            <vdr-chip colorType="error" *ngIf="!modification.isSettled">{{
+                                'order.modification-not-settled' | translate
+                            }}</vdr-chip>
+                            <vdr-history-entry-detail>
+                                <vdr-modification-detail
+                                    [order]="order"
+                                    [modification]="modification"
+                                ></vdr-modification-detail>
+                            </vdr-history-entry-detail>
+                        </ng-container>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_PAYMENT_TRANSITION">
+                        <ng-container *ngIf="entry.data.to === 'Settled'; else regularPaymentTransition">
+                            <div class="title">
+                                {{ 'order.history-payment-settled' | translate }}
+                            </div>
+                            {{ 'order.transaction-id' | translate }}: {{ getPayment(entry)?.transactionId }}
+                            <vdr-history-entry-detail *ngIf="getPayment(entry) as payment">
+                                <vdr-payment-detail
+                                    [payment]="payment"
+                                    [currencyCode]="order.currencyCode"
+                                ></vdr-payment-detail>
+                            </vdr-history-entry-detail>
+                        </ng-container>
+                        <ng-template #regularPaymentTransition>
+                            {{
+                                'order.history-payment-transition'
+                                    | translate
+                                        : {
+                                              from: entry.data.from,
+                                              to: entry.data.to,
+                                              id: getPayment(entry)?.transactionId
+                                          }
+                            }}
+                        </ng-template>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_REFUND_TRANSITION">
+                        {{
+                            'order.history-refund-transition'
+                                | translate
+                                    : { from: entry.data.from, to: entry.data.to, id: entry.data.refundId }
+                        }}
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_CANCELLATION">
+                        {{
+                            'order.history-items-cancelled'
+                                | translate: { count: entry.data.orderItemIds.length }
+                        }}
+                        <vdr-history-entry-detail *ngIf="getCancelledItems(entry) as items">
+                            <vdr-labeled-data [label]="'order.cancellation-reason' | translate">
+                                {{ entry.data.reason }}
+                            </vdr-labeled-data>
+                            <vdr-labeled-data [label]="'order.contents' | translate">
+                                <vdr-simple-item-list [items]="items"></vdr-simple-item-list>
+                            </vdr-labeled-data>
+                            <vdr-labeled-data [label]="'order.shipping-cancelled' | translate">
+                                {{ entry.data.shippingCancelled }}
+                            </vdr-labeled-data>
+                        </vdr-history-entry-detail>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_FULFILLMENT">
+                        {{ 'order.history-fulfillment-created' | translate }}
+                        <vdr-history-entry-detail *ngIf="getFulfillment(entry) as fulfillment">
+                            <vdr-fulfillment-detail
+                                [fulfillmentId]="fulfillment.id"
+                                [order]="order"
+                            ></vdr-fulfillment-detail>
+                        </vdr-history-entry-detail>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_FULFILLMENT_TRANSITION">
+                        <ng-container *ngIf="entry.data.to === 'Delivered'">
+                            <div class="title">
+                                {{ 'order.history-fulfillment-delivered' | translate }}
+                            </div>
+                            {{ 'order.tracking-code' | translate }}: {{ getFulfillment(entry)?.trackingCode }}
+                        </ng-container>
+                        <ng-container *ngIf="entry.data.to === 'Shipped'">
+                            <div class="title">
+                                {{ 'order.history-fulfillment-shipped' | translate }}
+                            </div>
+                            {{ 'order.tracking-code' | translate }}: {{ getFulfillment(entry)?.trackingCode }}
+                        </ng-container>
+                        <ng-container *ngIf="entry.data.to !== 'Delivered' && entry.data.to !== 'Shipped'">
+                            {{
+                                'order.history-fulfillment-transition'
+                                    | translate: { from: entry.data.from, to: entry.data.to }
+                            }}
+                        </ng-container>
+                        <vdr-history-entry-detail *ngIf="getFulfillment(entry) as fulfillment">
+                            <vdr-fulfillment-detail
+                                [fulfillmentId]="fulfillment.id"
+                                [order]="order"
+                            ></vdr-fulfillment-detail>
+                        </vdr-history-entry-detail>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_NOTE">
+                        <div class="flex">
+                            <div class="note-text">
+                                <span *ngIf="entry.isPublic" class="note-visibility public">{{
+                                    'common.public' | translate
+                                }}</span>
+                                <span *ngIf="!entry.isPublic" class="note-visibility private">{{
+                                    'common.private' | translate
+                                }}</span>
+                                {{ entry.data.note }}
+                            </div>
+                            <div class="flex-spacer"></div>
+                            <vdr-dropdown>
+                                <button class="icon-button" vdrDropdownTrigger>
+                                    <clr-icon shape="ellipsis-vertical"></clr-icon>
+                                </button>
+                                <vdr-dropdown-menu vdrPosition="bottom-right">
+                                    <button
+                                        class="button"
+                                        vdrDropdownItem
+                                        (click)="updateNote.emit(entry)"
+                                        [disabled]="!('UpdateOrder' | hasPermission)"
+                                    >
+                                        <clr-icon shape="edit"></clr-icon>
+                                        {{ 'common.edit' | translate }}
+                                    </button>
+                                    <div class="dropdown-divider"></div>
+                                    <button
+                                        class="button"
+                                        vdrDropdownItem
+                                        (click)="deleteNote.emit(entry)"
+                                        [disabled]="!('UpdateOrder' | hasPermission)"
+                                    >
+                                        <clr-icon shape="trash" class="is-danger"></clr-icon>
+                                        {{ 'common.delete' | translate }}
+                                    </button>
+                                </vdr-dropdown-menu>
+                            </vdr-dropdown>
+                        </div>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_COUPON_APPLIED">
+                        {{ 'order.history-coupon-code-applied' | translate }}:
+                        <vdr-chip>
+                            <a [routerLink]="['/marketing', 'promotions', entry.data.promotionId]">{{
+                                entry.data.couponCode
+                            }}</a>
+                        </vdr-chip>
+                    </ng-container>
+                    <ng-container *ngSwitchCase="type.ORDER_COUPON_REMOVED">
+                        {{ 'order.history-coupon-code-removed' | translate }}:
+                        <vdr-chip
+                            ><span class="cancelled-coupon-code">{{ entry.data.couponCode }}</span></vdr-chip
+                        >
+                    </ng-container>
+                    <ng-container *ngSwitchDefault>
+                        <div class="title">
+                            {{ entry.type | translate }}
+                        </div>
+                        <vdr-history-entry-detail *ngIf="entry.data">
+                            <vdr-object-tree [value]="entry.data"></vdr-object-tree>
+                        </vdr-history-entry-detail>
+                    </ng-container>
                 </ng-container>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_PAYMENT_TRANSITION">
-                <ng-container *ngIf="entry.data.to === 'Settled'; else regularPaymentTransition">
-                    <div class="title">
-                        {{ 'order.history-payment-settled' | translate }}
-                    </div>
-                    {{ 'order.transaction-id' | translate }}: {{ getPayment(entry)?.transactionId }}
-                    <vdr-history-entry-detail *ngIf="getPayment(entry) as payment">
-                        <vdr-payment-detail
-                            [payment]="payment"
-                            [currencyCode]="order.currencyCode"
-                        ></vdr-payment-detail>
-                    </vdr-history-entry-detail>
-                </ng-container>
-                <ng-template #regularPaymentTransition>
-                    {{
-                        'order.history-payment-transition'
-                            | translate
-                                : {
-                                      from: entry.data.from,
-                                      to: entry.data.to,
-                                      id: getPayment(entry)?.transactionId
-                                  }
-                    }}
-                </ng-template>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_REFUND_TRANSITION">
-                {{
-                    'order.history-refund-transition'
-                        | translate: { from: entry.data.from, to: entry.data.to, id: entry.data.refundId }
-                }}
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_CANCELLATION">
-                {{ 'order.history-items-cancelled' | translate: { count: entry.data.orderItemIds.length } }}
-                <vdr-history-entry-detail *ngIf="getCancelledItems(entry) as items">
-                    <vdr-labeled-data [label]="'order.cancellation-reason' | translate">
-                        {{ entry.data.reason }}
-                    </vdr-labeled-data>
-                    <vdr-labeled-data [label]="'order.contents' | translate">
-                        <vdr-simple-item-list [items]="items"></vdr-simple-item-list>
-                    </vdr-labeled-data>
-                    <vdr-labeled-data [label]="'order.shipping-cancelled' | translate">
-                        {{ entry.data.shippingCancelled }}
-                    </vdr-labeled-data>
-                </vdr-history-entry-detail>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_FULFILLMENT">
-                {{ 'order.history-fulfillment-created' | translate }}
-                <vdr-history-entry-detail *ngIf="getFulfillment(entry) as fulfillment">
-                    <vdr-fulfillment-detail
-                        [fulfillmentId]="fulfillment.id"
-                        [order]="order"
-                    ></vdr-fulfillment-detail>
-                </vdr-history-entry-detail>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_FULFILLMENT_TRANSITION">
-                <ng-container *ngIf="entry.data.to === 'Delivered'">
-                    <div class="title">
-                        {{ 'order.history-fulfillment-delivered' | translate }}
-                    </div>
-                    {{ 'order.tracking-code' | translate }}: {{ getFulfillment(entry)?.trackingCode }}
-                </ng-container>
-                <ng-container *ngIf="entry.data.to === 'Shipped'">
-                    <div class="title">
-                        {{ 'order.history-fulfillment-shipped' | translate }}
-                    </div>
-                    {{ 'order.tracking-code' | translate }}: {{ getFulfillment(entry)?.trackingCode }}
-                </ng-container>
-                <ng-container *ngIf="entry.data.to !== 'Delivered' && entry.data.to !== 'Shipped'">
-                    {{
-                        'order.history-fulfillment-transition'
-                            | translate: { from: entry.data.from, to: entry.data.to }
-                    }}
-                </ng-container>
-                <vdr-history-entry-detail *ngIf="getFulfillment(entry) as fulfillment">
-                    <vdr-fulfillment-detail
-                        [fulfillmentId]="fulfillment.id"
-                        [order]="order"
-                    ></vdr-fulfillment-detail>
-                </vdr-history-entry-detail>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_NOTE">
-                <div class="flex">
-                    <div class="note-text">
-                        <span *ngIf="entry.isPublic" class="note-visibility public">{{
-                            'common.public' | translate
-                        }}</span>
-                        <span *ngIf="!entry.isPublic" class="note-visibility private">{{
-                            'common.private' | translate
-                        }}</span>
-                        {{ entry.data.note }}
-                    </div>
-                    <div class="flex-spacer"></div>
-                    <vdr-dropdown>
-                        <button class="icon-button" vdrDropdownTrigger>
-                            <clr-icon shape="ellipsis-vertical"></clr-icon>
-                        </button>
-                        <vdr-dropdown-menu vdrPosition="bottom-right">
-                            <button
-                                class="button"
-                                vdrDropdownItem
-                                (click)="updateNote.emit(entry)"
-                                [disabled]="!('UpdateOrder' | hasPermission)"
-                            >
-                                <clr-icon shape="edit"></clr-icon>
-                                {{ 'common.edit' | translate }}
-                            </button>
-                            <div class="dropdown-divider"></div>
-                            <button
-                                class="button"
-                                vdrDropdownItem
-                                (click)="deleteNote.emit(entry)"
-                                [disabled]="!('UpdateOrder' | hasPermission)"
-                            >
-                                <clr-icon shape="trash" class="is-danger"></clr-icon>
-                                {{ 'common.delete' | translate }}
-                            </button>
-                        </vdr-dropdown-menu>
-                    </vdr-dropdown>
-                </div>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_COUPON_APPLIED">
-                {{ 'order.history-coupon-code-applied' | translate }}:
-                <vdr-chip>
-                    <a [routerLink]="['/marketing', 'promotions', entry.data.promotionId]">{{
-                        entry.data.couponCode
-                    }}</a>
-                </vdr-chip>
-            </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_COUPON_REMOVED">
-                {{ 'order.history-coupon-code-removed' | translate }}:
-                <vdr-chip
-                    ><span class="cancelled-coupon-code">{{ entry.data.couponCode }}</span></vdr-chip
-                >
-            </ng-container>
-            <ng-container *ngSwitchDefault>
-                <div class="title">
-                    {{ entry.type | translate }}
-                </div>
-                <vdr-history-entry-detail *ngIf="entry.data">
-                    <vdr-object-tree [value]="entry.data"></vdr-object-tree>
-                </vdr-history-entry-detail>
-            </ng-container>
-        </ng-container>
-    </vdr-timeline-entry>
+            </vdr-timeline-entry>
+        </ng-template>
+    </ng-container>
+
     <vdr-timeline-entry [isLast]="true" [createdAt]="order.createdAt" [featured]="true">
         <div class="title">
             {{ 'order.history-order-created' | translate }}

+ 10 - 3
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.ts

@@ -1,11 +1,12 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
 import {
     GetOrderHistory,
-    HistoryEntry,
+    HistoryEntryComponentService,
     HistoryEntryType,
     OrderDetail,
     OrderDetailFragment,
     TimelineDisplayType,
+    TimelineHistoryEntry,
 } from '@vendure/admin-ui/core';
 
 @Component({
@@ -18,13 +19,19 @@ export class OrderHistoryComponent {
     @Input() order: OrderDetailFragment;
     @Input() history: GetOrderHistory.Items[];
     @Output() addNote = new EventEmitter<{ note: string; isPublic: boolean }>();
-    @Output() updateNote = new EventEmitter<HistoryEntry>();
-    @Output() deleteNote = new EventEmitter<HistoryEntry>();
+    @Output() updateNote = new EventEmitter<TimelineHistoryEntry>();
+    @Output() deleteNote = new EventEmitter<TimelineHistoryEntry>();
     note = '';
     noteIsPrivate = true;
     expanded = false;
     readonly type = HistoryEntryType;
 
+    constructor(private historyEntryComponentService: HistoryEntryComponentService) {}
+
+    hasCustomComponent(type: string): boolean {
+        return !!this.historyEntryComponentService.getComponent(type);
+    }
+
     getDisplayType(entry: GetOrderHistory.Items): TimelineDisplayType {
         if (entry.type === HistoryEntryType.ORDER_STATE_TRANSITION) {
             if (entry.data.to === 'Delivered') {

+ 5 - 3
packages/admin-ui/src/lib/order/src/order.module.ts

@@ -4,6 +4,7 @@ import { 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';
+import { CouponCodeSelectorComponent } from './components/coupon-code-selector/coupon-code-selector.component';
 import { DraftOrderDetailComponent } from './components/draft-order-detail/draft-order-detail.component';
 import { DraftOrderVariantSelectorComponent } from './components/draft-order-variant-selector/draft-order-variant-selector.component';
 import { FulfillOrderDialogComponent } from './components/fulfill-order-dialog/fulfill-order-dialog.component';
@@ -17,6 +18,7 @@ import { OrderCustomFieldsCardComponent } from './components/order-custom-fields
 import { OrderDetailComponent } from './components/order-detail/order-detail.component';
 import { OrderEditorComponent } from './components/order-editor/order-editor.component';
 import { OrderEditsPreviewDialogComponent } from './components/order-edits-preview-dialog/order-edits-preview-dialog.component';
+import { OrderHistoryEntryHostComponent } from './components/order-history/order-history-entry-host.component';
 import { OrderHistoryComponent } from './components/order-history/order-history.component';
 import { OrderListComponent } from './components/order-list/order-list.component';
 import { OrderPaymentCardComponent } from './components/order-payment-card/order-payment-card.component';
@@ -30,13 +32,12 @@ import { PaymentDetailComponent } from './components/payment-detail/payment-deta
 import { PaymentStateLabelComponent } from './components/payment-state-label/payment-state-label.component';
 import { RefundOrderDialogComponent } from './components/refund-order-dialog/refund-order-dialog.component';
 import { RefundStateLabelComponent } from './components/refund-state-label/refund-state-label.component';
+import { SelectAddressDialogComponent } from './components/select-address-dialog/select-address-dialog.component';
 import { SelectCustomerDialogComponent } from './components/select-customer-dialog/select-customer-dialog.component';
+import { SelectShippingMethodDialogComponent } from './components/select-shipping-method-dialog/select-shipping-method-dialog.component';
 import { SettleRefundDialogComponent } from './components/settle-refund-dialog/settle-refund-dialog.component';
 import { SimpleItemListComponent } from './components/simple-item-list/simple-item-list.component';
 import { orderRoutes } from './order.routes';
-import { SelectAddressDialogComponent } from './components/select-address-dialog/select-address-dialog.component';
-import { CouponCodeSelectorComponent } from './components/coupon-code-selector/coupon-code-selector.component';
-import { SelectShippingMethodDialogComponent } from './components/select-shipping-method-dialog/select-shipping-method-dialog.component';
 
 @NgModule({
     imports: [SharedModule, RouterModule.forChild(orderRoutes)],
@@ -75,6 +76,7 @@ import { SelectShippingMethodDialogComponent } from './components/select-shippin
         SelectAddressDialogComponent,
         CouponCodeSelectorComponent,
         SelectShippingMethodDialogComponent,
+        OrderHistoryEntryHostComponent,
     ],
 })
 export class OrderModule {}

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

@@ -15,6 +15,7 @@ export * from './components/order-custom-fields-card/order-custom-fields-card.co
 export * from './components/order-detail/order-detail.component';
 export * from './components/order-editor/order-editor.component';
 export * from './components/order-edits-preview-dialog/order-edits-preview-dialog.component';
+export * from './components/order-history/order-history-entry-host.component';
 export * from './components/order-history/order-history.component';
 export * from './components/order-list/order-list.component';
 export * from './components/order-payment-card/order-payment-card.component';