Search code examples
angularjsangulardecorator

Is there a way to replace an Angular component by a component in a customer module like when using the decorator in AngularJS?


I am developing an application for a wide customer range. Different customers tend to have different needs for customization in their UI. Therefore, we would like to replace those components by customer-specific components. Unfortunately, this seems to be impossible. Could anyone be of help?

The situation we would like:

  • Module 'Base'
    • Component 'Scheduler' - displaying a couple of components from template
      • Component 'SchedulerEvent' (tag 'scheduler-event') with some basic data
  • Module 'Customer'
    • Component 'CustomerSchedulerEvent' (tag 'scheduler-event') with customer-specific data

In this situation, we would like to have the CustomerSchedulerEvent displayed instead of the normal SchedulerEvent. Although the code compiles properly this way, still the SchedulerEvent is displayed.

In old AngularJS code, there was the decorator concept which could replace entire directives/components, which is being described here: https://docs.angularjs.org/guide/decorators#directive-decorator-example.

Is there a possibility to get kind-of this behavior working in modern Angular as well?!


Solution

  • Although quite cumbersome, at least there appears to be a workaround:

    1. Make sure you have a component host directive. We will use that one later.
    @Directive({
        selector: '[componentHost]',
    })
    export class ComponentHostDirective {
        constructor(readonly $viewContainerRef: ViewContainerRef) { }
    }
    
    1. Create a service returning the component type to render:
    import { Injectable, Type } from '@angular/core';
    [...]
    @Injectable()
    export class TemplateComponentService extends TemplateComponentBaseService {
        getTemplate(): Type<LaneSubscriberSchedulingEventInformationTemplateBaseComponent> {
            // console.log('Our EventInformationTemplateService...');
            return LaneSubscriberSchedulingEventInformationTemplateComponent;
        }
    }
    
    

    which you register as follows in your base module:

    @NgModule({
        ...
        providers: [
            EventInformationTemplateService,
            { provide: EventInformationTemplateBaseService, useExisting: EventInformationTemplateService }
        ]
    })
    export class BaseModule {
    }
    
    1. Your component that could be replaced, should look like:
    import { AfterViewInit, Component, ComponentFactoryResolver, ElementRef, Type, ViewChild } from "@angular/core";
    import { ComponentHostDirective } from "src/common/directives/component-host.directive";
    import { AggregateServiceFactory } from "src/common/services/aggregates";
    import { LaneSubscriberSchedulingEventInformationTemplateBaseComponent } from "src/production-planning/components/lane-subscriber-scheduling/event-information-template/event-information-template-base.component";
    import { EventInformationTemplateBaseService } from "src/production-planning/components/lane-subscriber-scheduling/event-information-template/event-information-template-base.service";
    import { moduleName } from "src/production-planning/production-planning.states";
    
    @Component({
        selector: 'app-template',
        templateUrl: './template.component.html',
    
    })
    export class TemplateComponent extends TemplateBaseComponent implements AfterViewInit {
        componentType: Type<LaneSubscriberSchedulingEventInformationTemplateBaseComponent>;
        customComponent: boolean;
    
        @ViewChild(ComponentHostDirective, { static: true }) private _componentHost: ComponentHostDirective;
    
        constructor(
            $element: ElementRef,
            private readonly $componentFactoryResolver: ComponentFactoryResolver,
            private readonly _templateComponentService: TemplateComponentBaseService) {
    
            this.componentType = this._templateComponentService.getComponent();
            this.customComponent = !isNull(this.componentType) && this.componentType !== TemplateComponent;
    
            // console.group('TemplateComponentService.getComponent()');
            // console.log('Component type', this.componentType);
            // console.log('Component custom?', this.customComponent);
            // console.groupEnd();
        }
    
        // Component lifecycle events
        ngAfterViewInit(): void {
            if (this.customComponent === true) {
                const componentFactory = this.$componentFactoryResolver.resolveComponentFactory(this.componentType);
                this._componentHost.$viewContainerRef.clear();
                const componentRef = this._componentHost.$viewContainerRef.createComponent<LaneSubscriberSchedulingEventInformationTemplateBaseComponent>(componentFactory);
                componentRef.instance.event = this.event;
            }
        }
    }
    

    and its template file like:

    <ng-container *ngIf="customComponent != true">
        <!-- TODO Display more information -->
        <strong>{{ event.title }}</strong>
    </ng-container>
    <ng-template componentHost></ng-template>
    
    1. Create another service like the one above and register it the same way in another module in your app.

    As you can see, we hide the original component content using *ngIf and use the component host on the ng-template to render the replacing component in its place when the service returns another type than the current type. The reason to opt for this strange path, is that the tag will directly map to our base template component and is not replaceable.

    A drawback for this scenario is that the component host directive, being a ViewChild, is only available after view init, which is quite late. For very complex scenarios, this workaround could therefore lead to some unwanted timing issues and such...

    I hope anyone could provide a better solution?