import { ValidationConfig } from '@allianz/taly-core';
import {
Component,
ComponentRef,
effect,
HostBinding,
Injector,
input,
OnDestroy,
OnInit,
output,
signal,
ViewChild,
ViewContainerRef,
WritableSignal
} 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';
@Component({
selector: 'df-formfield',
templateUrl: './formfield.component.html',
styleUrls: ['./formfield.component.scss'],
standalone: false
})
export class DfFormfieldComponent implements OnInit, OnDestroy {
config = input<DfBaseConfig>();
private componentInstance: WritableSignal<DfBaseComponent<DfBaseConfig> | undefined> =
signal(undefined);
validationConfigs = input<ValidationConfig[]>();
formAclPath = input<string>();
defaultSpacing = input<DfFormfieldSpacing>();
isRetailChannel = input.required<boolean>();
readonly groupChanged = output<UntypedFormGroup>();
@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(
private injector: Injector,
private componentService: DfComponentLoaderService,
private parentFormGroup: FormGroupDirective
) {
effect(() => {
const componentInstance = this.componentInstance();
if (componentInstance && this.validationConfigs()) {
this.component?.setInput('validationConfigs', this.validationConfigs());
}
});
}
ngOnInit() {
this.groupChanged.subscribe(() => {
this.componentOrControlInitFinished.next();
});
this.group = this.parentFormGroup.form;
// The ngOnInit call ignores the async, so be careful what you do here:
this.createAndInsertFormfieldComponent();
}
ngOnDestroy(): void {
// Delay the removal to prevent an ExpressionChangedAfterItHasBeenCheckedError:
setTimeout(() => {
this.containerRef?.clear();
if (this.formControl) {
// Reset the form, which also removes the data from this field from the state.
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
const configValue = this.config();
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());
// Subscribe to changes to component's AbstractControl
outputToObservable(componentInstance.controlChanged)
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe((control) => {
// The controlChanged output was an asynchronous EventEmitter in the past. With the migration
// to an output function, this changed to be synchronous, which lead to an ExpressionChangedAfterItHasBeenCheckedError.
// Using a setTimeout here ensures that the behavior is the same as with the previous asynchronous EventEmitter.
setTimeout(() => {
this.onFormControlChanged(control);
}, 0);
});
outputToObservable(componentInstance.formEvent)
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe((event) => {
this.formEvent.emit(event);
});
// If the component doesn't implement a control, fire the componentOrControlInitFinished
// to tell others, that the component is finished. But if the component decided to use the deferSetupControl, we wait for it to finish.
if (!componentInstance.control()) {
if (componentInstance.deferSetupControl) {
componentInstance.componentOrControlInitFinished
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe(this.componentOrControlInitFinished);
} else {
this.componentOrControlInitFinished.next();
}
}
}
}
private onFormControlChanged = (formControl: AbstractControl | undefined) => {
const configValue = this.config();
if (!configValue?.id) {
return;
}
this.formControl = formControl;
const group = this.safelyGetFormGroup();
if (group.get(configValue.id)) {
throw new Error(
`Dynamic Form: The form control with the ID "${configValue.id}" already exists in the the formGroup.`
);
}
group.setControl(configValue.id, formControl);
this.groupChanged.emit(group);
};
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();
}
}
@use '../breakpoints.scss' as *;
:host {
--df-formfield-spacing-desktop-default: var(--vertical-inner-section-spacing);
--df-formfield-spacing-mobile-default: var(--vertical-inner-section-spacing);
&:has(df-headline) {
--df-formfield-spacing-desktop-default: 0px !important;
}
display: block;
::ng-deep {
// Removes NDBX formfield paddings
--formfield-bottom-padding: 0;
--formfield-mobile-bottom-padding: 0;
}
&:not(:empty) {
/**
* Formfield components can override the default spacing by overriding the following tokens:
* --df-formfield-spacing-desktop-default
* --df-formfield-spacing-mobile-default
*/
margin-bottom: var(--df-formfield-spacing-desktop-default);
&.retail {
margin-bottom: var(--df-formfield-spacing-desktop-default);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-default);
}
}
&.df-spacing-none {
margin-bottom: var(--df-formfield-spacing-none, 0);
}
&.df-spacing-xs {
margin-bottom: var(--df-formfield-spacing-desktop-xs, 8px);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-xs, 8px);
}
}
&.df-spacing-s {
margin-bottom: var(--df-formfield-spacing-desktop-s, 16px);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-s, 8px);
}
}
&.df-spacing-m {
margin-bottom: var(--df-formfield-spacing-desktop-m, 24px);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-m, 16px);
}
}
&.df-spacing-l {
margin-bottom: var(--df-formfield-spacing-desktop-l, 32px);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-l, 24px);
}
}
&.df-spacing-xl {
margin-bottom: var(--df-formfield-spacing-desktop-xl, 40px);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-xl, 32px);
}
}
&.df-spacing-xxl {
margin-bottom: var(--df-formfield-spacing-desktop-xxl, 48px);
@media (max-width: $breakpoint-m) {
margin-bottom: var(--df-formfield-spacing-mobile-xxl, 40px);
}
}
}
&:empty {
display: none;
}
}
// Removes last formfield's margin-bottom.
::ng-deep df-form {
df-formfield:last-of-type {
margin-bottom: 0 !important;
}
}