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 '../model';
import { CHANNEL_TOKEN } from '../tokens';
import { dasherize } from '../utils';
import { ValidationRule } from '../validation-config.model';
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>();
validationConfigurationChanged$ = new EventEmitter<ValidationRule[]>();
/**
* 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;
}