import { createAclPath, type AclRule } from '@allianz/taly-acl';
import {
ACL_TAG_TOKEN,
AclInspectorService,
AclService,
wrapAclEvaluationWithTag
} from '@allianz/taly-acl/angular';
import { autoFormBindingFactory, FormBindingReturnValue } from '@allianz/taly-acl/form-support';
import {
applyValidationConfig,
CHANNEL,
CHANNEL_TOKEN,
cleanupValidationHandling,
ValidationConfig,
ValidationConfigItem
} from '@allianz/taly-core';
import {
ChangeDetectorRef,
Component,
effect,
inject,
input,
model,
OnDestroy,
OnInit,
output,
QueryList,
signal,
untracked,
ViewChildren
} from '@angular/core';
import { outputFromObservable, outputToObservable, toObservable } from '@angular/core/rxjs-interop';
import { FormGroup, type UntypedFormGroup } from '@angular/forms';
import isEqual from 'lodash/isEqual';
import { combineLatest, Subject, type ReplaySubject } from 'rxjs';
import { distinctUntilChanged, map, take, takeUntil } from 'rxjs/operators';
import {
BaseDynamicFormConfiguration,
DfBaseConfig,
type DfEventPayload,
type DfFormfieldSpacing
} from '../base/base.model';
import { DfFormfieldComponent } from '../formfield/formfield.component';
import { DfFormLayout, DfFormLayoutClassName } from '../utils/form-layout/form-layout.model';
const ContainerLayout = {
SingleColumn: 'single-column',
MultiColumn: 'multi-column',
CustomColumn: 'custom-column'
} as const;
type ContainerLayout = (typeof ContainerLayout)[keyof typeof ContainerLayout];
@Component({
selector: 'df-form',
templateUrl: './form.component.html',
styleUrls: ['./form.component.scss'],
standalone: false
})
export class DfFormComponent implements OnInit, OnDestroy {
id = input.required<string>();
formConfig = input<BaseDynamicFormConfiguration | undefined>(undefined);
/**
* This optional input allows it to hand over a formGroup that the Dynamic Form should use.
* It will then add the formControls of the fields directly to this formGroup, instead of creating a new one.
*/
existingFormGroup = input<FormGroup | undefined>();
formValid = output<boolean>();
formRawValue = model<Record<string, unknown> | undefined>(undefined);
formGroupChange = output<UntypedFormGroup>();
protected formFieldsHiddenStatus: Map<string, boolean> = new Map();
private aclInspectorService = inject(AclInspectorService);
readonly formEvent = output<DfEventPayload>();
/**
* 1. The PFE Facade enriches the validation configuration with state subscriptions for state-driven values
* within the validations. Outside a Building Block Platform Journey, the standalone Dynamic Form handles
* this potential enrichment.
*
* 2. The enriched validation configuration is forwarded directly to the TALY `applyValidationConfig()` method,
* which applies the validators to the actual form controls.
*/
enrichedValidationConfiguration = input<ValidationConfigItem[]>();
protected form!: FormGroup;
protected formFieldConfig?: DfBaseConfig[];
protected formFieldValidationConfigs: (ValidationConfig[] | undefined)[] = [];
protected spacing?: DfFormfieldSpacing;
private renderingInProgress = signal<boolean>(true);
/**
* formInitFinished will be triggered, once all components within the dynamic form have finished
* their initialization.
*/
formInitFinished = outputFromObservable(
toObservable(this.renderingInProgress).pipe(map((renderingInProgress) => !renderingInProgress))
);
containerLayout: ContainerLayout = ContainerLayout.SingleColumn;
@ViewChildren('dynamicFormField') dynamicFormFields!: QueryList<DfFormfieldComponent>;
columnClassList: string[] = [];
isRetailChannel = inject(CHANNEL_TOKEN) === CHANNEL.RETAIL;
private tearDownForm$ = new Subject<void>();
protected formAclPath?: string;
private aclFormBinding?: FormBindingReturnValue;
private aclService: AclService = inject(AclService);
private aclTag = inject(ACL_TAG_TOKEN, {
optional: true
});
formDataToBeRestoredAfterRendering: Record<string, unknown> | undefined = undefined;
private cd = inject(ChangeDetectorRef);
constructor() {
effect(() => {
// This represents the two-way binding of the raw form value:
const formRawValue = this.formRawValue();
if (formRawValue && !isEqual(formRawValue, this.form.getRawValue())) {
this.form.patchValue(formRawValue);
}
});
effect(() => {
if (this.renderingInProgress()) return;
if (!this.formFieldConfig) {
this.formFieldValidationConfigs = [];
return;
}
const validationConfig = this.enrichedValidationConfiguration();
if (validationConfig && validationConfig.length > 0) {
const validationMap = applyValidationConfig(this.form, validationConfig);
this.formFieldValidationConfigs = this.formFieldConfig.map((formField) =>
validationMap.get(formField.id)
);
}
});
// When the formConfig input changes, we re-render the form:
effect(() => {
this.updateFormConfig(this.formConfig());
});
}
ngOnInit() {
this.form = this.existingFormGroup() ?? new FormGroup({});
this.formGroupChange.emit(this.form);
}
/**
* This tears down the old form and renders a new one, if a configuration is given
*/
updateFormConfig(newFormConfig?: BaseDynamicFormConfiguration) {
this.backupFormRawValue();
// We cleanup the validation handling of TALY before we change the form configuration
// Otherwise it would re-apply outdated validation configs automatically to fields with
// IDs that existed before
cleanupValidationHandling(this.form);
this.renderingInProgress.set(true);
this.formFieldConfig = undefined;
this.tearDownForm$.next();
// Drop a hint to Angular to destroy the child components.
// Without this, the previous instances stays alive and interfere with the new ones.
this.cd.detectChanges();
if (!newFormConfig || !newFormConfig.fields || newFormConfig.fields.length === 0) {
return;
}
newFormConfig = structuredClone({
...newFormConfig,
fields: this.withoutConsecutiveLineBreakFields(newFormConfig.fields)
});
this.formFieldConfig = newFormConfig.fields;
this.determineLayout(newFormConfig.layout);
this.initAcl(newFormConfig);
this.subscribeToElementControls();
}
private backupFormRawValue() {
const formRawValue = untracked(this.formRawValue);
this.formDataToBeRestoredAfterRendering = this.form.getRawValue() as Record<string, unknown>;
if (
formRawValue &&
JSON.stringify(this.formDataToBeRestoredAfterRendering) !== JSON.stringify(formRawValue)
) {
// Mismatch between form data and formRawValue detected, that means the input
// value from formRawValue has not been applied to the form yet.
// Let's use that one:
this.formDataToBeRestoredAfterRendering = formRawValue;
}
}
/**
* When the components get rendered, this method will subscribe to all the componentOrControlInitFinished
* subjects and trigger all the post-rendering actions.
*/
private subscribeToElementControls() {
// we need to run this code asynchronously to ensure that the form is fully initialized
setTimeout(() => {
this.dynamicFormFields.forEach((dynamicFormField: DfFormfieldComponent) => {
outputToObservable(dynamicFormField.formEvent)
.pipe(takeUntil(this.tearDownForm$))
.subscribe((event: DfEventPayload) => {
this.formEvent.emit(event);
});
});
const formElementControls: ReplaySubject<void>[] = this.dynamicFormFields.map(
(dynamicFormField) => dynamicFormField.componentOrControlInitFinished
);
combineLatest(formElementControls)
// takeUntil(this.tearDownForm$) is needed to prevent memory leaks when the configuration is changed before the rendering is finished
.pipe(takeUntil(this.tearDownForm$), take(1))
.subscribe(() => {
if (this.formDataToBeRestoredAfterRendering) {
this.form.patchValue(this.formDataToBeRestoredAfterRendering);
this.formDataToBeRestoredAfterRendering = undefined;
}
this.setupFormChangesSubscriptions();
this.setupAclBinding();
this.renderingInProgress.set(false);
});
}, 0);
}
private setupFormChangesSubscriptions(): void {
this.form.valueChanges
.pipe(takeUntil(this.tearDownForm$), distinctUntilChanged(isEqual))
.subscribe(() => this.formRawValue.set(this.form.getRawValue()));
this.form.statusChanges
.pipe(takeUntil(this.tearDownForm$), distinctUntilChanged())
.subscribe(() => {
const formValid = this.form.valid || this.form.disabled;
this.formValid.emit(formValid);
});
}
private setupAclBinding(): void {
/**
* 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`
*/
const wrappedAclService = this.aclTag
? wrapAclEvaluationWithTag(this.aclService, this.aclTag)
: this.aclService;
this.aclFormBinding = autoFormBindingFactory()(wrappedAclService, this.form);
// In the original this was takeUntilDestroyed(this.destroyRef)
// Which has the drawback that the ACL subscribers (for example the syncControlView())
// are only garbage collected when this component is destroyed.
// Until then they accumulate and stay in memory on every configuration change/re-render
// of the Dynamic Form
// The this.cleanupAclFormBindingSubscriptions is triggered on every configuration change/re-render
// and allows for them to garbage collected immediately.
this.aclFormBinding.stream$.pipe(takeUntil(this.tearDownForm$)).subscribe();
}
private initAcl(config: BaseDynamicFormConfiguration) {
this.aclFormBinding = undefined;
this.aclService.removeDynamicFormRules(this.id());
// This collects all the ACL elements above the Dynamic Form in the DOM:
const getFullAclPath = (aclResource: string): string => {
const aclKey = this.aclTag ? this.aclTag.aclKey : undefined;
const pathElements = [aclKey, aclResource].filter(
(element): element is NonNullable<typeof element> => element !== undefined
);
return createAclPath(pathElements);
};
this.formAclPath = getFullAclPath('');
const injectAclRules = (config: BaseDynamicFormConfiguration) => {
const aclRules: AclRule[] = [];
for (const field of config.fields) {
if (field.acl?.length) {
for (const acl of field.acl) {
aclRules.push({
active: true,
path: getFullAclPath(field.id),
condition: acl.condition ?? '',
state: acl.state,
defaultRule: false,
dynamicFormRule: true
});
}
}
}
if (config.acl?.length) {
for (const acl of config.acl) {
aclRules.push({
active: true,
path: getFullAclPath(acl.path),
condition: acl.condition ?? '',
state: acl.state,
defaultRule: false,
dynamicFormRule: true
});
}
}
this.aclService.addDynamicFormRules(this.id(), aclRules);
};
// For multi-column layouts, we need to hide the formfields that contain
// a form control hidden with ACL. Otherwise, they block space as if they were shown
const setFormFieldsVisibility = (config: BaseDynamicFormConfiguration) => {
for (const field of config.fields) {
this.formFieldsHiddenStatus.set(field.id, false);
const fullAclPath = getFullAclPath(field.id);
combineLatest([
this.aclService.isHidden$(fullAclPath),
this.aclInspectorService.showAclHints$
])
.pipe(takeUntil(this.tearDownForm$))
.subscribe(([hiddenWithAcl, showingAclHints]) => {
this.formFieldsHiddenStatus.set(field.id, hiddenWithAcl && !showingAclHints);
});
}
};
injectAclRules(config);
setFormFieldsVisibility(config);
}
determineLayout(layout?: DfFormLayout) {
if (this.formFieldConfig) {
if (layout) {
this.containerLayout = this.determineContainerLayout(layout);
this.columnClassList = this.getColumnClassList(layout, this.formFieldConfig);
} else {
this.containerLayout = ContainerLayout.SingleColumn;
this.columnClassList = this.getColumnClassList(
DfFormLayout.OneColumn,
this.formFieldConfig
);
}
}
}
private determineContainerLayout(layout: DfFormLayout) {
if (layout === DfFormLayout.OneColumn) {
return ContainerLayout.SingleColumn;
}
if (layout === DfFormLayout.CustomColumn) {
return ContainerLayout.CustomColumn;
}
return ContainerLayout.MultiColumn;
}
getColumnClassList(layout: DfFormLayout, config: DfBaseConfig[]) {
return config.map((config) => {
if (config.type === 'LINE_BREAK') {
return `line-break-element`;
}
return `${DfFormLayoutClassName[layout]} column column-${config.columnSpan ?? 12}`;
});
}
ngOnDestroy(): void {
this.tearDownForm$.next();
this.aclService.removeDynamicFormRules(this.id());
}
private withoutConsecutiveLineBreakFields(fields: DfBaseConfig[]): DfBaseConfig[] {
return fields.filter((field, index) => {
if (field.type === 'LINE_BREAK') {
return fields[index - 1]?.type !== 'LINE_BREAK';
}
return true;
});
}
}