The goal of the feature is to make a component replaceable at runtime. This replacement mechanism integrated with application customization relies on Angular component factories in order to create the component at runtime. You can see a lot of details in the link above, and you'll probably notice that it is very verbose and cumbersome to implement:
- Inputs and outputs have to be bound again, outside the template
- Change detection has to be adapted in order to pass potential changes of those inputs when the parent component changes
In order to alleviate that, we provide a directive c11n
that, when applied to an ng-template
and given the following information, will handle all the wiring behind the scene:
- The type of the component to instantiate
- The inputs and outputs to bind
- The potential configuration override to apply
Now let's see how to set this mechanism up to create your replacable components.
Firstly we need to prepare the base app to have the extensibility of providers. To do this you need to create a new variable in your app.module.ts that can extend the providers, lets call it customProviders.
import {initializeEntryComponents, registerCustomComponents} from '../customization/presenters-map.empty';
import {C11nModule} from '@o3r/components';
...
const entry = initializeEntryComponents();
@NgModule({
imports: [
...
C11nModule.forRoot({registerCompFunc: registerCustomComponents}),
...entry.customComponentsModules
],
...
})
We also need to create 2 functions initializeEntryComponents and registerCustomComponents that will initialize the values for the base application so the app compiles. We'll do that in a customization folder src/customization. This is just an "empty shell" since it is just adding an empty array to the customComponents and an empty array to the custom modules. It will register an empty map of custom components. However, it allows the customization app to replace these empty functions with functions which provides the setup for custom components.
import {EntryCustomComponents} from '@o3r/components';
export function registerCustomComponents(): Map<string, any> {
return new Map();
}
export function initializeEntryComponents(): EntryCustomComponents {
return {
customComponents: [],
customComponentsModules: []
};
}
Once the base app is prepared, the customization app can be configured to use the customization. Firstly a file which will provide custom components configs to the application needs to be created:
-
Could be named:
custo-app-folder/white-label/src/customization/component-replacement-map.ts
. -
In your
custom-config.js
, register your mapping"customComponentsFile": "component-replacement-map.ts"
so the framework will use this file instead ofpresenters-map.empty.ts
which is here by default so that the application compiles. -
Within the replacement file you will register the custom components like this:
import {EntryCustomComponents, registerCustomComponent} from '@o3r/components';
import {ExamplePresComponent} from './example/example-pres.component';
import {ExamplePresModule} from './example/index';
/**
* @example
* ```typescript
* const firstCompMap = registerCustomComponent(new Map(), 'firstCompKey', FirstComponent);
* const secondCompMap = registerCustomComponent(firstCompMap, 'secondCompKey', SecondComponent);
* return secondCompMap;
* ```
*/
export function registerCustomComponents(): Map<string, any> {
return registerCustomComponent(new Map(), 'exampleCustomPres', ExamplePresComponent);
}
/** Returns the array of custom components and the array of associated components modules */
export function initializeEntryComponents(): EntryCustomComponents {
return {
customComponents: [ExamplePresComponent],
customComponentsModules: [ExamplePresModule]
};
}
- Once you run the
apply customization
script, theangular.json
of the application will be modified to replace thepresenters-map.empty.ts
with your own file, meaning that your application module will now import your custom components.
Last part to do is to tell to the parent component to replace a default subcomponent with the custom one. To do that we have to simply provide a configuration to the parent component. Here is an example:
var body = window.document.getElementsByTagName('body')[0]
body.setAttribute('data-bootstrapconfig', JSON.stringify({
// Configuration override going here
}));
body.setAttribute('data-staticconfig', JSON.stringify([
{
name: 'o3r-example-cont',
config: {exampleCustomPresKey: 'exampleCustomPres'},
library: '@scope/o3r-components'
}
]));
This configuration key's value is used at runtime to lookup in the component replacement map which decides the component class to create.
In this section we will detail how to make a subcomponent replaceable by this mechanism with a simple, almost empty component.
Here we need to do two things:
- Import the module related to customization:
C11nModule
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {C11nModule} from '@o3r/components';
import {DummyContComponent} from './dummy-cont.component';
import {DummyPresComponent} from '../presenter/dummy-pres.component';
@NgModule({
imports: [CommonModule, C11nModule],
declarations: [DummyContComponent],
exports: [DummyContComponent],
})
export class ExampleContModule {}
Since the objective is to replace a component with another, we need a place to hold the information of the key of the component we want to use.
That key will correspond to what is added by the application via registerCustomComponent
.
That information is expected to be a property of the component's configuration as it can be exposed by the CMS and edited by a customer. Since configurations have to have a default value, in this specific instance it must always be the empty string. The default presenter will be declared in the component's class.
import { computeConfigurationName, Configuration } from '@o3r/configuration';
export interface DummyContConfig extends Configuration {
/** Key used to identify a custom component, if provided */
customDummyKey: string;
}
export const DUMMY_CONT_DEFAULT_CONFIG: DummyContConfig = {
customDummyKey: ''
}
export const DUMMY_CONT_CONFIG_ID = computeConfigurationName('DummyContConfig', '@scope/o3r-components');
For more information on configuration, you can check this documentation.
The context of the subcomponent is used to define the contract to interact with your component, defining the set of dynamic inputs and outputs that a component has. It is structured into three interfaces:
*ContextInput
interface (e.g.DummyPresContextInput
): contains all the inputs of a component. Fields must have a documentation.*ContextOutput
interface (e.g.DummyPresContextOutput
): contains all the outputs of a component. Fields must have a documentation.DummyPresContext
interface: brings togetherContextInput
andContextOutput
, extendingContext<DummyPresContextInput, DummyPresContextOutput>
from@o3r/core
.
import {Context} from '@o3r/core';
export interface DummyPresContextInput {
/** Example of input */
dummyInput: string;
}
export interface DummyPresContextOutput {
/** Example of output */
onDummyOutput: number;
}
export interface DummyPresContext extends Context<DummyPresContextInput, DummyPresContextOutput> {}
Here we need to do a couple of things:
- Retrieve the component's configuration as described in the configuration overview
- Inject the
C11nService
- Compute a
presenter$
observable by applying thec11nService.getPresenter()
operator to ourconfig$
observable, specifying the default presenter to use - Prepare an
outputs
object
import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, Optional, Type} from '@angular/core';
import {DummyContConfig, DUMMY_CONT_CONFIG_ID, DUMMY_CONT_DEFAULT_CONFIG} from './dummy-cont.config';
import {DummyContContext} from './dummy-cont.context';
import {DummyPresContext} from '../presenter/index';
import {DummyPresComponent} from '../presenter/dummy-pres.component';
import {ConfigurationBaseService, ConfigurationObserver} from '@o3r/configuration';
import {Block} from '@o3r/core';
import {C11nService} from '@o3r/components';
import {Observable} from 'rxjs';
@Component({
selector: 'o3r-dummy-cont',
templateUrl: './dummy-cont.template.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DummyContComponent implements DynamicConfigurable<DummyContConfig>, DummyContContext, OnInit, OnDestroy, Block {
/** Input configuration to override the default configuration of the component
*/
@Input()
public config: Partial<DummyContConfig> | undefined;
/** Dynamic configuration based on the input override configuration and the configuration service if used by the application */
private dynamicConfig$: ConfigurationObserver<DummyContConfig>;
/** Configuration stream based on the input and the stored configuration */
public config$: Observable<DummyContConfig>;
/** Observable of the presenter that we want to use, processed by the c11n directive */
public presenter$: Observable<Type<DummyPresContext>>;
/** Convenience object to prepare all the outputs binding in advance */
public outputs: Functionify<DummyPresContextOutput>;
constructor(private c11nService: C11nService,
@Optional() private configurationService?: ConfigurationBaseService) {
// Retrieve the component's configuration
this.dynamicConfig$ = new ConfigurationObserver<DummyContConfig>(DUMMY_CONT_CONFIG_ID, DUMMY_CONT_DEFAULT_CONFIG, this.configurationService);
this.config$ = this.dynamicConfig$.asObservable();
// Load the right presenter
this.loadPresenter();
}
private loadPresenter() {
this.presenter$ = this.config$.pipe(
// Compute which presenter to use according to the configuration and the default presenter that we define here
this.c11nService.getPresenter(DummyPresComponent, 'customDummyPresKey')
);
this.outputs = {
onDummyOutput: this.dummyOutput.bind(this)
};
}
public dummyOutput(event: number) {
console.log('output', event);
}
}
Since the presenter used by the container will be decided at runtime, we won't use any selector in our container's template.
Instead, we will simply use an ng-template
tag to which we apply the Otter c11n
directive, passing it the various things computed in the component's class:
- The
presenter$
observable, that will tell which presenter component to use - The
inputs
, a map that wraps all the inputs that the container passes to the presenter - The
config
override, if the container wants to override part of the presenter's configuration - The
outputs
, a map that wraps all the handlers that the container wants to bind to the presenter
<ng-template c11n
[component]="presenter$ | async"
[inputs]="{dummyInput: 'dummy'}"
[config]="{}"
[outputs]="outputs"
></ng-template>
The main limitation is that it is not possible to apply any modifications to the host
component created by a factory.
This means that any of those following are not possible through an ng-template
and c11n
combination:
<!-- Host binding -->
<o3r-dummy-cont [class]="dynamicClass"></o3r-dummy-cont>
<!-- Applying directives to the component -->
<o3r-dummy-cont customDirective></o3r-dummy-cont>
Though there is a solution for the first example in making class
an input, and bind it inside the component using
the HostBinding decorator, there is no actual solution for applying directive.
Attribute | Pattern |
---|---|
Context file name | *.context.ts |
Context interface names | *ContextInput / *ContextOutput / *Context |