File

libs/core/src/lib/building-block/abstract-building-block.ts

Index

Properties

Properties

event
Type string
loading
Type boolean
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;
}

results matching ""

    No results matching ""