libs/core/dynamic-form/src/formfield/formfield.component.ts
Properties |
| isHidden | |
| Type |
Signal<boolean>
|
import { ValidationConfig } from '@allianz/taly-core';
import {
Component,
ComponentRef,
effect,
HostBinding,
Injector,
input,
OnDestroy,
OnInit,
output,
signal,
Signal,
ViewChild,
ViewContainerRef,
WritableSignal,
inject
} from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { AbstractControl, FormGroupDirective, UntypedFormGroup } from '@angular/forms';
import { ReplaySubject, Subject, takeUntil } from 'rxjs';
import { DfBaseComponent } from '../base/base.component';
import { DfBaseConfig, type DfEventPayload, DfFormfieldSpacing } from '../base/base.model';
import { DfComponentLoaderService } from './../services/component-loader/component-loader.service';
import { AclExtendedFormGroup } from '@allianz/taly-acl/form-support';
interface DfHideable {
readonly isHidden: Signal<boolean>;
}
function isHideable(obj: unknown): obj is DfHideable {
return (
obj != null &&
typeof obj === 'object' &&
'isHidden' in obj &&
typeof (obj as DfHideable).isHidden === 'function'
);
}
@Component({
selector: 'df-formfield',
templateUrl: './formfield.component.html',
styleUrls: ['./formfield.component.scss'],
host: {
'[attr.data-df-id]': 'config()?.id',
'[attr.data-df-type]': 'config()?.type'
},
standalone: false
})
export class DfFormfieldComponent implements OnInit, OnDestroy {
private injector = inject(Injector);
private componentService = inject(DfComponentLoaderService);
private parentFormGroup = inject(FormGroupDirective);
config = input.required<DfBaseConfig>();
private componentInstance: WritableSignal<DfBaseComponent<DfBaseConfig> | undefined> =
signal(undefined);
private hasHiddenChild = signal(false);
@HostBinding('class.has-hidden-child')
get hasHiddenChildClass() {
return this.hasHiddenChild();
}
validationConfigs = input<ValidationConfig[]>();
formAclPath = input<string>();
defaultSpacing = input<DfFormfieldSpacing>();
isRetailChannel = input.required<boolean>();
@ViewChild('container', { read: ViewContainerRef, static: true }) containerRef?: ViewContainerRef;
readonly formEvent = output<DfEventPayload>();
/**
* Additionally to the groupChanged EventEmitter a ReplaySubject is provided.
* In difference to the EventEmitter, this one always contains the last emitted value,
* which allows to check if a component was initialized after the initialization already happened.
* (An event will be gone at that point in time.)
*
* This subject will fire with an undefined value, after the configuration was passed to the component,
* if the component doesn't have a form control.
*/
componentOrControlInitFinished = new ReplaySubject<void>(1);
/**
* Defines the spacing of the formfield.
*/
@HostBinding('class.df-spacing-xs') get isSpacingXS() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.xs);
}
@HostBinding('class.df-spacing-s') get isSpacingS() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.s);
}
@HostBinding('class.df-spacing-m') get isSpacingM() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.m);
}
@HostBinding('class.df-spacing-l') get isSpacingL() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.l);
}
@HostBinding('class.df-spacing-xl') get isSpacingXL() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.xl);
}
@HostBinding('class.df-spacing-xxl') get isSpacingXXL() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.xxl);
}
@HostBinding('class.df-spacing-none') get isSpacingNone() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.none);
}
private tearDownComponentSubscriptions$ = new Subject<void>();
group: UntypedFormGroup = new UntypedFormGroup({});
private formControl?: AbstractControl;
component?: ComponentRef<DfBaseComponent>;
constructor() {
effect(() => {
const componentInstance = this.componentInstance();
if (componentInstance && this.validationConfigs()) {
this.component?.setInput('validationConfigs', this.validationConfigs());
}
});
effect(() => {
const componentInstance = this.componentInstance();
if (isHideable(componentInstance)) {
this.hasHiddenChild.set(componentInstance.isHidden());
}
});
}
ngOnInit() {
this.group = this.parentFormGroup.form;
// The ngOnInit call ignores the async, so be careful what you do here:
this.createAndInsertFormfieldComponent();
}
ngOnDestroy(): void {
// Store this.config() in a variable, because it might not be available in the setTimeout callback,
// Angular cleans up inputs shortly after ngOnDestroy completes.
const configValue = this.config();
// Delay the removal to prevent an ExpressionChangedAfterItHasBeenCheckedError:
setTimeout(() => {
this.containerRef?.clear();
if (
this.formControl &&
configValue &&
!(this.group as AclExtendedFormGroup).__aclSyncedViewControls?.has(configValue.id)
) {
// Reset the form, which also removes the data from this field from the state.
// ACL hidden controls are not removed from the form onDestroy, in order to be able to cache them.
this.formControl.reset();
}
// Remove old FormControl from group
// There is a catch here: Removing the control also cancels the reset event.
// TODO: Nice to have: Find a solution that ensures that the removal is only done
// after the reset is handled everywhere.
// The following ensures that only the control of this component is removed from the group.
// It is possible, that the control was replaced by another one with the same ID in the meantime.
// This syntax "this.group.get([configValue.id])" is needed instead of this one "this.group.get(configValue.id)" because
// otherwise the "get" function will use "." in the ids as a key separator
if (configValue && this.group && this.group.get([configValue.id]) === this.formControl) {
this.group.removeControl(configValue.id);
}
this.tearDownComponentSubscriptions$.next();
});
}
private checkFormfieldSpacing(spacing: DfFormfieldSpacing): boolean {
const configValue = this.config();
if (configValue?.spacing) {
return configValue.spacing === spacing;
}
return this.defaultSpacing() === spacing;
}
private safelyGetFormGroup(): UntypedFormGroup {
if (!this.group) {
this.group = new UntypedFormGroup({});
}
return this.group;
}
private configureComponent() {
// Stop listening to any previously configured component
this.tearDownComponentSubscriptions$.next();
const componentInstance = this.component?.instance;
this.componentInstance.set(componentInstance);
const configValue = this.config();
if (componentInstance && configValue) {
// Pass down the props
this.component?.setInput('config', configValue);
this.component?.setInput('isRetailChannel', this.isRetailChannel());
this.component?.setInput('formAclPath', this.formAclPath());
// If the formGroup has an ACL cached control for this field, use it.
const group = this.safelyGetFormGroup();
if (
group.get([configValue.id]) &&
(group as AclExtendedFormGroup).__aclSyncedViewControls?.has(configValue.id)
) {
this.component?.setInput('control', group.get([configValue.id]));
this.component?.setInput('isAclHandled', true);
}
// Subscribe to changes to component's AbstractControl
componentInstance.componentOrControlInitFinished
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe((control) => {
// Using a setTimeout here prevents an ExpressionChangedAfterItHasBeenCheckedError error.
setTimeout(() => {
this.onFormControlChanged(control);
}, 0);
});
outputToObservable(componentInstance.formEvent)
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe((event) => {
this.formEvent.emit(event);
});
}
}
private onFormControlChanged = (formControl: AbstractControl | undefined) => {
const configValue = this.config();
if (configValue?.type === 'LINE_BREAK') {
this.componentOrControlInitFinished.next();
return;
}
if (!configValue?.id) {
return;
}
this.formControl = formControl;
const group = this.safelyGetFormGroup();
group.setControl(configValue.id, formControl);
this.componentOrControlInitFinished.next();
};
private async createAndInsertFormfieldComponent() {
const configValue = this.config();
if (!configValue?.type) {
return;
}
// Create desired form field component
const { component, moduleRef } = await this.componentService.getComponent(configValue);
this.component = this.containerRef?.createComponent<DfBaseComponent>(
component as unknown as typeof DfBaseComponent,
{
ngModuleRef: moduleRef,
injector: this.injector
}
);
this.configureComponent();
}
}