libs/core/src/lib/building-block/abstract-building-block.ts
STATE: Highly fluctuating, expect many refactoring and breaking changes.
Our Building Blocks Foundation: The abstract Building Block definition. We need to prepare and implement everything to allow Building Block to deliver the following features:
Good things that might help us: A) Split different responsibilities into mixins B) Provide a Building Block Decorator, maybe that's easier than extending or beneficial in addition (to be checked) C) Keep Lifecycle out of the interface, it's internal knowledge in the end. D) Proper Code Inspection, extended use of Typescript to ensure: State is matching, Feature Flags are matching etc.
Properties |
Methods |
Inputs |
Outputs |
Accessors |
constructor(injector: Injector)
|
||||||
Parameters :
|
id | |
Type : any
|
|
Default value : dasherize(this.constructor.name)
|
|
can be changed during runtime and given as a default by the implemented Building Block |
resources | |
Type : BuildingBlockResources
|
|
The method will be called by the facade
and is marked as an @Input. Derived Building Blocks need to list
the input in the @Component decorator under |
state | |
Type : NoArray<BuildingBlockState>
|
|
The method will be called by the facade
and is marked as an @Input. Derived Building Blocks need to list
the input in the @Component decorator under |
completed | |
Type : EventEmitter
|
|
Mark this Building Block as complete. That way a workflow engine can determine the overall completion state of any given pages. This method can be actively triggered by the user (say a button is clicked) or indirectly by a form being changed. The author of a Building Block needs to make sure that this function is called during the lifetime of a Building Block. If the Building Block is purely presentational this method can be called during initialization. |
callBusinessEvent | ||||||||
callBusinessEvent(event: string)
|
||||||||
This method creates a Deferred object for the business event given. This will have a newly created promise and their executor parameters resolve & reject. This deferred object is then emitted along with the business event name The Store Integration(Facade) can then decide when to resolve or reject the underlying promise based on the service activator call attached to the business event. call's status.
Parameters :
Returns :
Promise<>
the promise from the deferred. The building block can then be notified on the respective service activator call's status. |
commitCompletion |
commitCompletion()
|
Returns :
void
|
getForm |
getForm()
|
This is supposed to return the building block's form. Leave this untouched if your building block implementation does not have a form.
Returns :
UntypedFormGroup | undefined
|
getState |
getState()
|
⚠️ If this function shows an error that contains Retrieve the current state of this Building Block. It's an empty object by default. The state should only include data that is useful to the outside world. Don't expose internal states like scroll position, button press states etc. Think of it as data that is useful in an overall business process and that you would also recognize in written insurance contracts for example. The author of a Building Block needs to implement this function in order to deliver a proper snapshot of the current state of all components and/or forms.
Returns :
NoArray<BuildingBlockState>
|
navigate | |||||||||
navigate(type: BUILDING_BLOCK_NAVIGATION_TYPE, payload?: string)
|
|||||||||
Parameters :
Returns :
void
|
onPageConnection |
onPageConnection()
|
Returns :
void
|
onPageDisconnected |
onPageDisconnected()
|
Returns :
void
|
revertCompletion |
revertCompletion()
|
Returns :
void
|
setResources | ||||||
setResources(data: BuildingBlockResources | undefined)
|
||||||
Parameters :
Returns :
void
|
setState | ||||||
setState(state: NoArray<BuildingBlockState>)
|
||||||
Parameters :
Returns :
void
|
setValidationConfiguration | ||||||
setValidationConfiguration(data: ValidationConfigItem[])
|
||||||
Parameters :
Returns :
void
|
stateChanged |
stateChanged()
|
Returns :
void
|
transformResources | ||||||
transformResources(data: BuildingBlockResources)
|
||||||
Override if you need to pre process the incoming resource data and if you want to establish some default values. The return value will arrive in setResources and stored in the 'resources' variable. Needs to match the Resource Generic of the given Building Block
Parameters :
Returns :
BuildingBlockResources
|
transformState | ||||||
transformState(data: NoArray<BuildingBlockState>)
|
||||||
Override if you need to pre process the incoming state data and if you want to establish some default values. The return value will arrive in setState and stored in the 'state' variable. Needs to match the State Generic of the given Building Block
Parameters :
Returns :
NoArray<BuildingBlockState>
|
_aclService |
Type : AclService
|
aclTag |
Type : AclTag | null
|
businesssEventCall$ |
Default value : new EventEmitter<BusinessEvent>()
|
Readonly channel |
Type : CHANNEL
|
completion$ |
Default value : new BehaviorSubject(false)
|
disconnected$ |
Default value : new Subject<void>()
|
Readonly isExpertChannel |
Type : boolean
|
Readonly isRetailChannel |
Type : boolean
|
loadingStatus$ |
Default value : this._loadingStatus$
.asObservable()
.pipe(
scan(accumulateBusinessEvents, []),
map(groupEventStatusesByName),
distinctUntilChanged(isEqual)
)
|
navigateEvent$ |
Default value : new EventEmitter<BuildingBlockNavigationEvent>()
|
resources | ||||||
getresources()
|
||||||
setresources(data: BuildingBlockResources)
|
||||||
The method will be called by the facade
and is marked as an @Input. Derived Building Blocks need to list
the input in the @Component decorator under
Parameters :
Returns :
void
|
state | ||||||
getstate()
|
||||||
setstate(data: NoArray<BuildingBlockState>)
|
||||||
The method will be called by the facade
and is marked as an @Input. Derived Building Blocks need to list
the input in the @Component decorator under
Parameters :
Returns :
void
|
acl |
getacl()
|
import { AclEvaluationInterface, AclTag } from '@allianz/taly-acl';
import { ACL_TAG_TOKEN, AclService, wrapAclEvaluationWithTag } from '@allianz/taly-acl/angular';
import { Directive, EventEmitter, Inject, Injector, Input, OnInit, Output } from '@angular/core';
import { UntypedFormGroup } from '@angular/forms';
import isEqual from 'lodash/isEqual';
import { BehaviorSubject, Subject } from 'rxjs';
import { distinctUntilChanged, map, scan, take } from 'rxjs/operators';
import { ValidationConfigItem } from '../form-support/validation.model';
import { CHANNEL } from '../models/model';
import { CHANNEL_TOKEN } from '../tokens';
import { dasherize } from '../utils';
import { BuildingBlockInterface, BusinessEvent, Deferred } from './building-block-interface';
import { BUILDING_BLOCK_NAVIGATION_TYPE, BuildingBlockNavigationEvent } from './navigation';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type NoArray<T> = T extends any[] ? never : T;
/**
* Why don't we use Record<string, any> here?
* Unfortunately, this is not fully supported by the json schema generator.
* With the usage of Record, the type safety there is lost.
*
* See also: https://github.com/YousefED/typescript-json-schema/issues/547
*
* (In reality, this does not make a real difference in the AbstractBuildingBlock,
* but it is what Building Blocks should also do)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RecordObjectLike = Record<string, any>;
/**
* STATE: Highly fluctuating, expect many refactoring and breaking changes.
*
* Our Building Blocks Foundation: The abstract Building Block definition.
* We need to prepare and implement everything to allow Building Block to deliver the following features:
*
* 1. User State
* 2. Resources (deliver required data like Dropdown lists, things to display only)
* 3. Support Completion (Facade/PFE Integration)
* 4. Support Various Lifecycle Methods: onPageConnection (via Facade)
*
* Good things that might help us:
* A) Split different responsibilities into mixins
* B) Provide a Building Block Decorator, maybe that's easier than extending or beneficial in addition (to be checked)
* C) Keep Lifecycle out of the interface, it's internal knowledge in the end.
* D) Proper Code Inspection, extended use of Typescript to ensure:
* State is matching, Feature Flags are matching etc.
*/
@Directive()
export class AbstractBuildingBlock<
BuildingBlockState extends RecordObjectLike | undefined = Record<string, unknown>,
BuildingBlockResources = Record<string, unknown> | undefined
> implements BuildingBlockInterface<BuildingBlockState, BuildingBlockResources>, OnInit
{
_aclService: AclService;
aclTag: AclTag | null;
readonly channel: CHANNEL;
readonly isRetailChannel: boolean;
readonly isExpertChannel: boolean;
businesssEventCall$ = new EventEmitter<BusinessEvent>();
navigateEvent$ = new EventEmitter<BuildingBlockNavigationEvent>();
/**
* Can be used to add automatic tracking to a form. Keep in mind that the form that is returned by getForm()
* is tracked automatically during the initialization.
*
* This only has to be called if there are other forms that should be tracked or if the form
* is created after the initialization.
*/
trackForm$ = new EventEmitter<UntypedFormGroup>();
private isConnected = false;
constructor(@Inject(Injector) injector: Injector) {
this._aclService = injector.get<AclService>(AclService);
this.aclTag = injector.get<AclTag | null>(ACL_TAG_TOKEN, null);
this.channel = injector.get<CHANNEL>(CHANNEL_TOKEN);
this.isRetailChannel = this.channel === CHANNEL.RETAIL;
this.isExpertChannel = this.channel === CHANNEL.EXPERT;
this.connected$.pipe(take(1)).subscribe(() => (this.isConnected = true));
this.disconnected$.pipe(take(1)).subscribe(() => (this.isConnected = false));
}
/**
* This method creates a Deferred object for the business event given. This will have a newly created promise and their
* executor parameters resolve & reject. This deferred object is then emitted along with the business event name
*
* The Store Integration(Facade) can then decide when to resolve or reject the underlying promise based on the service
* activator call attached to the business event.
*
* @param event name of the business event
* @returns the promise from the deferred. The building block can then be notified on the respective service activator
* call's status.
*/
callBusinessEvent(event: string): Promise<unknown> {
const isJestEnvironment = 'test' in globalThis && 'describe' in globalThis;
if (!this.isConnected && !isJestEnvironment) {
throw new Error(
`
Can't call business event "${event}" for Building Block "${this.id}" because it's not yet connected to the page.
A Building Block must be connected to a page for it to be able to call 'this.callBusinessEvent()'.
You're likely calling 'this.callBusinessEvent("${event}")' from inside the Building Block's constructor or 'ngOnInit()' method.
Please move the call to 'this.callBusinessEvent()' into the Building Block's 'onPageConnection()' method.
`
);
}
const deferred = createDeferred();
this._loadingStatus$.next({ event, loading: true });
this.businesssEventCall$.emit({ event, deferred });
return deferred.promise.finally(() => this._loadingStatus$.next({ event, loading: false }));
}
navigate(type: BUILDING_BLOCK_NAVIGATION_TYPE, payload?: string) {
const event: BuildingBlockNavigationEvent = {
type
};
if (payload) {
event.payload = payload;
}
this.navigateEvent$.emit(event);
}
/**
* can be changed during runtime and given as a default
* by the implemented Building Block
*/
@Input() id = dasherize(this.constructor.name);
// eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method
ngOnInit(): void {}
/**
* Mark this Building Block as complete.
* That way a workflow engine can determine the overall completion state of any given pages.
* This method can be actively triggered by the user (say a button is clicked) or indirectly by
* a form being changed.
*
* The author of a Building Block needs to make sure that this function is called during the lifetime of a Building Block.
* If the Building Block is purely presentational this method can be called during initialization.
*
*/
// That's useful to hook into the state while a Building Block is mounted in a template.
@Output() completed = new EventEmitter();
// have a separate Behaviour Subject to always reflect the current state of completion.
completion$ = new BehaviorSubject(false);
commitCompletion() {
this.completion$.next(true);
this.completed.emit(true);
}
revertCompletion() {
this.completion$.next(false);
this.completed.emit(false);
}
/**
* Lifecycle Event called once the Building Block is connected to the page.
* This can only happen when the Building Block is embed in a Building Block Page
* This is not intended to be used standalone.
*
* At this point you can be sure that:
* 1. The Building Block is ready (AfterViewInit)
* 2. The initial state and resources has been set
*/
connected$ = new Subject<void>();
onPageConnection() {}
disconnected$ = new Subject<void>();
onPageDisconnected() {}
setValidationConfiguration(data: ValidationConfigItem[]) {}
/**
* RESOURCE HANDLING
*/
/**
* Provide data that is necessary for the Building Blocks to work.
* This can be lists of choices (for dropdowns) but also image urls, and other data
* that is not changeable by the user. It's pretty static although it could change depending
* of a user's choice (like a new set of dropdown values). This means there is a chance that the data will change during runtime
*
* This is very different from the Building Block state which can be changed by the user
* and can highly fluctuate between users.
*
* setResources is the internal hook that can be overwritten by a specific implementation.
* The getter & setter are meant as fixed implementations ensuring a proper order of invocations & storage of data
*/
private _resources!: BuildingBlockResources;
/**
* Override if you need to pre process the incoming resource data
* and if you want to establish some default values. The return value will arrive in setResources
* and stored in the 'resources' variable. Needs to match the Resource Generic of the given Building Block
*/
transformResources(data: BuildingBlockResources): BuildingBlockResources {
return data;
}
/**
* The method will be called by the facade
* and is marked as an @Input. Derived Building Blocks need to list
* the input in the @Component decorator under `inputs:[]` to make it available to the template (View Engine limitation).
*/
@Input()
set resources(data: BuildingBlockResources) {
this._resources = this.transformResources(data);
this.setResources(this._resources);
}
get resources(): BuildingBlockResources {
return this._resources;
}
setResources(data: BuildingBlockResources | undefined) {}
/**
* STATE HANDLING
*/
/**
* Call this function to grab the current state and forward it
* any time your internal state is ready to be forwarded.
* It's a BehaviorSubject to always deliver the latest state on subscription.
* TODO: Evaluate if a multicast could help maintaining an more coordiated data stream
*/
stateChange$ = new BehaviorSubject({} as BuildingBlockState);
stateChanged() {
this.stateChange$.next(this.getState());
}
/**
* Restore a previous state of this Building Block by
* passing in a state object the Building Block can use to
* hydrate the according components & forms.
*
* The author of a Building Block needs to override this function
* in order to process the given state properly.
*/
private _state!: NoArray<BuildingBlockState>;
/**
* Override if you need to pre process the incoming state data
* and if you want to establish some default values. The return value will arrive in setState
* and stored in the 'state' variable. Needs to match the State Generic of the given Building Block
*/
transformState(data: NoArray<BuildingBlockState>): NoArray<BuildingBlockState> {
return data;
}
/**
* The method will be called by the facade
* and is marked as an @Input. Derived Building Blocks need to list
* the input in the @Component decorator under `inputs:[]` to make it available to the template (View Engine limitation).
*/
@Input()
set state(data: NoArray<BuildingBlockState>) {
this._state = this.transformState(data);
this.setState(this._state);
}
get state(): NoArray<BuildingBlockState> {
return this._state;
}
setState(state: NoArray<BuildingBlockState>) {}
/**
* ⚠️ If this function shows an error that contains `Type '<some-type>[]' is not assignable to type 'never'.`,
* it means that you are trying to use an Array as your BB's State. This is not allowed. Please use an object instead.
*
* Retrieve the current state of this Building Block. It's an empty object by default.
* The state should only include data that is useful to the outside world. Don't expose internal states
* like scroll position, button press states etc. Think of it as data that is useful in an overall business
* process and that you would also recognize in written insurance contracts for example.
*
* The author of a Building Block needs to implement this function
* in order to deliver a proper snapshot of the current state of all components and/or forms.
*/
getState(): NoArray<BuildingBlockState> {
// TODO: That needs to throw an error if T is defined but getState is not overwritten.
return {} as NoArray<BuildingBlockState>;
}
get acl(): AclEvaluationInterface {
/**
* we need to expose our AclService bound to the given aclTag
* of this instance if any. This ensures that calls to
* this.acl.canShow will incorporate the given acl tag hierarchy.
* This is important for anything you do manually in the template
* or for supporting scripts like `autoFormBindingFactory`
*/
if (this.aclTag) {
return wrapAclEvaluationWithTag(this._aclService, this.aclTag);
}
return this._aclService;
}
/**
* This is supposed to return the building block's form.
* Leave this untouched if your building block implementation does not have a form.
*/
getForm(): UntypedFormGroup | undefined {
return undefined;
}
private _loadingStatus$ = new Subject<BusinessEventStatus>();
loadingStatus$ = this._loadingStatus$
.asObservable()
.pipe(
scan(accumulateBusinessEvents, []),
map(groupEventStatusesByName),
distinctUntilChanged(isEqual)
);
}
interface BusinessEventStatus {
event: string;
loading: boolean;
}
function accumulateBusinessEvents(arr = [] as BusinessEventStatus[], event: BusinessEventStatus) {
return [...arr, event];
}
function groupEventStatusesByName(arr: BusinessEventStatus[] = []) {
const grouped = arr.reduce((status, { event, loading }) => {
status[event] ??= 0;
status[event] += loading ? 1 : -1;
return status;
}, {} as Record<string, number>);
return Object.entries(grouped).reduce((status: Record<string, boolean>, [event, count]) => {
status[event] = count > 0;
return status;
}, {});
}
function createDeferred(): Deferred {
const deferred = {} as Deferred;
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred;
}