File
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
Index
Properties
|
|
Methods
|
|
Accessors
|
|
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.
|
onPageComplete
|
onPageComplete()
|
|
|
onPageDestroy
|
onPageDestroy()
|
|
|
onPageReady
|
onPageReady()
|
|
|
onPageWaiting
|
onPageWaiting()
|
|
|
connect$
|
Default value : new Subject<void>()
|
|
disconnect$
|
Default value : new Subject<void>()
|
|
facadeMap
|
Default value : new Map<string, AbstractBuildingBlockFacade>()
|
|
status
|
Default value : ABSTRACT_PAGE_STATUS.WAITING
|
|
Accessors
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();
}
}