File

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

Description

The abstract component any Building Block needs to inherit from in order to be discoverable. The page is not designed to connect & disconnect multiple times during the lifetime. We expect exactly one connect and one disconnect over the course of its lifetime.

That's why we also protect the methods connect & disconnect as they are not intended to be called from the outside in an unexpected order.

Implements

AfterViewInit OnDestroy OnInit

Index

Properties
Methods
Accessors

Constructor

constructor(injector: Injector)
Parameters :
Name Type Optional
injector Injector No

Methods

handleBuildBlocksChanged
handleBuildBlocksChanged(undefined: [AbstractBuildingBlock[], AbstractBuildingBlock[]], allowedRetries: number)

Whenever the set of Building Blocks has changed ensure that the matching facades are created & connected or respectively disconnected and deleted from our internal facade map. We chose this approach instead of reusing facades as the underlying component is in fact deleted and recreated by the Angular renderer so we would have to introduce extra logic to re-initialize that component. It's easier to pretend that we encounter a Building Block the first time.

Parameters :
Name Type Optional Default value
[AbstractBuildingBlock[], AbstractBuildingBlock[]] No
allowedRetries number No 2
Returns : void
handlePageStatusUpdates
handlePageStatusUpdates(newStatus: ABSTRACT_PAGE_STATUS)
Parameters :
Name Type Optional
newStatus ABSTRACT_PAGE_STATUS No
Returns : void
onPageComplete
onPageComplete()
Returns : void
onPageDestroy
onPageDestroy()
Returns : void
onPageReady
onPageReady()
Returns : void
onPageWaiting
onPageWaiting()
Returns : void

Properties

connect$
Default value : new Subject<void>()
disconnect$
Default value : new Subject<void>()
facadeMap
Default value : new Map<string, AbstractBuildingBlockFacade>()
Readonly id
Type : string
pageData
Type : PageDataForTemplate
Default value : {}
status
Default value : ABSTRACT_PAGE_STATUS.WAITING

Accessors

facades
getfacades()
buildingBlocks
getbuildingBlocks()
import { getNativeElement$ } from '@allianz/taly-acl/input-element-injector-directive';
import {
  AfterViewInit,
  ChangeDetectorRef,
  DestroyRef,
  Directive,
  Inject,
  Injector,
  OnDestroy,
  OnInit,
  QueryList,
  Renderer2,
  ViewChildren,
  inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, merge } from 'rxjs';
import { switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { AbstractBuildingBlock } from '../building-block/abstract-building-block';
import { AbstractBuildingBlockFacade } from '../facade/abstract-building-block-facade';
import { findErroneousFormControls } from '../form-support/validation';
import { TalyStickyService } from '../services/sticky.service';
import { type PageDataForTemplate } from '../services/taly-page-data.model';
import { TalyPageDataService } from '../services/taly-page-data.service';
import { TalyPageService } from '../services/taly-page.service';
import {
  ABSTRACT_PAGE_STATUS,
  createBuildBlockChangesStream,
  createPageStatusStream
} from './util';

/**
 * The abstract component any Building Block needs to inherit from
 * in order to be discoverable. The page is not designed to connect & disconnect
 * multiple times during the lifetime. We expect exactly one connect and one disconnect
 * over the course of its lifetime.
 *
 * That's why we also protect the methods connect & disconnect as they are not intended to be called from the outside
 * in an unexpected order.
 */
@Directive()
export class AbstractBuildingBlockPage<FacadeType extends AbstractBuildingBlockFacade>
  implements AfterViewInit, OnDestroy, OnInit
{
  pageData: PageDataForTemplate = {};

  disconnect$ = new Subject<void>();
  connect$ = new Subject<void>();
  status = ABSTRACT_PAGE_STATUS.WAITING;

  @ViewChildren(AbstractBuildingBlock)
  private _buildingBlockQueryList!: QueryList<AbstractBuildingBlock>;
  readonly id!: string;
  facadeMap = new Map<string, AbstractBuildingBlockFacade>();

  private _pageDataService: TalyPageDataService;
  private _pageService: TalyPageService;
  private _renderer: Renderer2;

  private changeDetectorRef = inject(ChangeDetectorRef);
  private talyStickyService = inject(TalyStickyService);

  protected destroyRef = inject(DestroyRef);

  constructor(@Inject(Injector) injector: Injector) {
    this._pageDataService = injector.get<TalyPageDataService>(TalyPageDataService);
    this._pageService = injector.get<TalyPageService>(TalyPageService);
    this._renderer = injector.get<Renderer2>(Renderer2);
  }

  get facades() {
    return Array.from(this.facadeMap.values());
  }

  onPageReady() {}

  onPageComplete() {}

  onPageWaiting() {}

  onPageDestroy() {}

  ngOnInit(): void {
    this._pageDataService.setPageId(this.id);
    this._pageDataService.storeData(this.pageData);
    this._pageService.nextPageRequested$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
      this.onNextPageRequested();
    });
  }

  ngAfterViewInit() {
    this.setupBuildingBlocks();
    this.connect();
    this.onPageReady();
    // Trigger change detection to prevent any "expression has changed" error
    this.changeDetectorRef.detectChanges();
  }

  protected setupBuildingBlocks() {
    const statusUpdate$ = createPageStatusStream(this._buildingBlockQueryList).pipe(
      tap((status) => this.handlePageStatusUpdates(status))
    );

    const buildingBlockUpdate$ = createBuildBlockChangesStream(this._buildingBlockQueryList).pipe(
      tap((data) => this.handleBuildBlocksChanged(data))
    );

    this.connect$
      .pipe(
        switchMap(() => {
          return merge(statusUpdate$, buildingBlockUpdate$);
        }),
        takeUntil(this.disconnect$)
      )
      .subscribe();

    this.disconnect$
      .pipe(
        take(1),
        tap(() => {
          this.facades.forEach((facade) => {
            facade.disconnect();
          });
        })
      )
      .subscribe();
  }

  /**
   * Whenever the set of Building Blocks has changed
   * ensure that the matching facades are created & connected or respectively disconnected and deleted
   * from our internal facade map. We chose this approach instead of reusing facades
   * as the underlying component is in fact deleted and recreated by the Angular renderer
   * so we would have to introduce extra logic to re-initialize that component. It's
   * easier to pretend that we encounter a Building Block the first time.
   */
  handleBuildBlocksChanged(
    [added, removed]: [AbstractBuildingBlock[], AbstractBuildingBlock[]],
    allowedRetries = 2
  ) {
    removed.forEach((item) => {
      const facade = this.facadeMap.get(item.id);
      if (facade) {
        facade.disconnect();
        this.facadeMap.delete(facade.id);
      }
    });

    const retryItems: AbstractBuildingBlock[] = [];

    added.forEach((item) => {
      const facade = this.createFacade(item);

      if (this.facadeMap.has(facade.id)) {
        if (allowedRetries === 0) {
          throw new Error(
            `The Building Block with ID '${facade.id}' collides with a previously encountered Building Block.`
          );
        }
        retryItems.push(item);
      } else {
        this.facadeMap.set(facade.id, facade);
        facade.connect();
      }
    });

    /*
        Why do we need a retry mechanism?
        refer: https://github.developer.allianz.io/ilt/taly-workspace/issues/867

        A building block can be either be shown in the content area or in sidebar area. When we resize the browser,
        it can happen that the building block is shown both in the content area and in the sidebar for a tick
        or two (As sidebar directive & sidebar component add/remove the building blocks at the same time and are not in sync with
        each other).

        So we need to wait until the existing building block is completely removed and then
        create the facade for it again.
    */

    if (allowedRetries > 0 && retryItems.length > 0) {
      setTimeout(() => this.handleBuildBlocksChanged([retryItems, []], allowedRetries - 1), 50);
    }
  }

  handlePageStatusUpdates(newStatus: ABSTRACT_PAGE_STATUS) {
    this.status = newStatus;

    if (this.status === ABSTRACT_PAGE_STATUS.COMPLETE) {
      this.onPageComplete();
    } else {
      this.onPageWaiting();
    }
  }

  protected connect() {
    this.connect$.next();
  }

  protected disconnect() {
    this.disconnect$.next();
  }

  /**
   * this finds the (visually) first erroneous form control
   * and then focuses it and scrolls to it
   */
  protected onNextPageRequested() {
    let firstErrorOffset = Infinity;
    let firstErrorControl: HTMLElement | undefined;

    this.facades.forEach((facade) => {
      const bbForm = facade.buildingBlock.getForm();
      if (!bbForm) return;

      facade.markFormGroupAsTouched();
      const erroneousControls = findErroneousFormControls(bbForm.controls);
      for (const control of erroneousControls) {
        const talyControlNativeElement$ = getNativeElement$(control);
        const controlRect = talyControlNativeElement$.value?.getBoundingClientRect();
        if (controlRect && controlRect.top < firstErrorOffset) {
          firstErrorOffset = controlRect.top;
          firstErrorControl = talyControlNativeElement$.value;
        }
      }
    });

    if (firstErrorControl) {
      firstErrorControl.focus({ preventScroll: true });
      // automatically have the browser scroll slightly above (40px) to ensure a control is fully visible
      const scrollMargin = this.talyStickyService.getStickyElementHeights() + 40;
      this._renderer.setStyle(firstErrorControl, 'scroll-margin-top', `${scrollMargin}px`);
      firstErrorControl.scrollIntoView({ behavior: 'smooth' });
    }
  }

  get buildingBlocks() {
    return this._buildingBlockQueryList.toArray();
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected createFacade(block: AbstractBuildingBlock): FacadeType {
    throw new Error('Provide a facade implementation');
  }

  ngOnDestroy(): void {
    this.disconnect();
    this.onPageDestroy();
  }
}

results matching ""

    No results matching ""