import { type AclRule, createAclPath } from '@allianz/taly-acl';
import { ACL_TAG_TOKEN, AclService, wrapAclEvaluationWithTag } from '@allianz/taly-acl/angular';
import { autoFormBindingFactory } from '@allianz/taly-acl/form-support';
import {
CHANNEL,
CHANNEL_TOKEN,
TalyValidationService,
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, ValidatorFn, type UntypedFormGroup } from '@angular/forms';
import isEqual from 'lodash-es/isEqual';
import { combineLatest, of, type ReplaySubject, Subject } from 'rxjs';
import {
distinctUntilChanged,
map,
startWith,
switchMap,
take,
takeUntil,
tap
} from 'rxjs/operators';
import {
BaseDynamicFormConfiguration,
DfBaseConfig,
type DfEventPayload,
type DfFormfieldSpacing,
SingleInputFieldLayout
} from '../base/base.model';
import { DfFormfieldComponent } from '../formfield/formfield.component';
import {
DfFormLayout,
DfFormLayoutClassName,
DfFormLayoutType
} from '../utils/form-layout/form-layout.model';
import { dasherize } from '@angular-devkit/core/src/utils/strings';
const ContainerLayout = {
SingleColumn: 'single-column',
MultiColumn: 'multi-column',
CustomColumn: 'custom-column'
} as const;
type ContainerLayout = (typeof ContainerLayout)[keyof typeof ContainerLayout];
type EnrichedFormFieldConfig = [
DfBaseConfig,
/** The ACL tag associated with this form field */
string
];
@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>();
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.
*/
transformedValidationConfiguration = input<ValidationConfigItem[]>();
protected form!: FormGroup;
protected formFieldConfig?: EnrichedFormFieldConfig[];
protected formFieldValidationConfigs: (ValidationConfig[] | undefined)[] = [];
protected spacing?: DfFormfieldSpacing;
protected renderingInProgress = signal<boolean>(true);
private formLoadingValidator: ValidatorFn = () => {
const loading = this.renderingInProgress();
return loading ? { formLoading: true } : null;
};
/**
* 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>();
private tearDownFieldChangesSubscriptions$ = new Subject<void>();
protected formAclPath?: string;
private aclService: AclService = inject(AclService);
private aclTag = inject(ACL_TAG_TOKEN, {
optional: true
});
formDataToBeRestoredAfterRendering: Record<string, unknown> | undefined = undefined;
private cd = inject(ChangeDetectorRef);
private talyValidationService = inject(TalyValidationService);
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.transformedValidationConfiguration();
if (validationConfig && validationConfig.length > 0) {
const validationMap = this.talyValidationService.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.form.addValidators(this.formLoadingValidator);
this.form.updateValueAndValidity();
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
this.talyValidationService.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) {
this.formRawValue.set(undefined);
return;
}
if (newFormConfig.fields.length === 0) {
this.formRawValue.set(undefined);
this.setupFormChangesSubscriptions();
this.renderingInProgress.set(false);
this.form.updateValueAndValidity();
return;
}
newFormConfig = structuredClone({
...newFormConfig,
fields: this.withoutConsecutiveLineBreakFields(newFormConfig.fields)
});
this.checkDuplicatedFields(newFormConfig);
this.determineLayout(newFormConfig);
this.formFieldConfig = this.enrichConfigWithAclTags(newFormConfig.fields);
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.
* This method also reacts to dynamic field visibility changes (via ACL) by using dynamicFormFields.changes.
*/
private subscribeToElementControls() {
// we need to run this code asynchronously to ensure that the form is fully initialized
setTimeout(() => {
this.waitForFieldsInitialization$()
.pipe(takeUntil(this.tearDownForm$))
.subscribe(() => {
this.setupAclBinding();
});
// React to dynamic field visibility changes
this.dynamicFormFields.changes
.pipe(
startWith(this.dynamicFormFields),
tap(() => {
this.tearDownFieldChangesSubscriptions$.next();
this.renderingInProgress.set(true);
this.form.updateValueAndValidity();
}),
tap(() => this.subscribeToFormEvents()),
switchMap(() => this.waitForFieldsInitialization$()),
takeUntil(this.tearDownForm$)
)
.subscribe(() => {
this.restoreFormData();
this.setupFormChangesSubscriptions();
this.renderingInProgress.set(false);
// After subscribing to the form, we ensure to update the status at least once.
// This is especially important for forms without any controls, as they would not emit any status at all otherwise.
this.form.updateValueAndValidity();
});
}, 0);
}
private restoreFormData(): void {
if (!this.formDataToBeRestoredAfterRendering) return;
const fieldsToRestore: Record<string, unknown> = {};
const pendingFields: Record<string, unknown> = {};
for (const [key, value] of Object.entries(this.formDataToBeRestoredAfterRendering)) {
const control = this.form.get([key]);
if (control) {
if (!control.dirty) {
fieldsToRestore[key] = value;
}
} else {
pendingFields[key] = value;
}
}
this.form.patchValue(fieldsToRestore);
this.formDataToBeRestoredAfterRendering =
Object.keys(pendingFields).length > 0 ? pendingFields : undefined;
}
private subscribeToFormEvents() {
this.dynamicFormFields.forEach((dynamicFormField: DfFormfieldComponent) => {
outputToObservable(dynamicFormField.formEvent)
.pipe(takeUntil(this.tearDownFieldChangesSubscriptions$))
.subscribe((event: DfEventPayload) => {
this.formEvent.emit(event);
});
});
}
private waitForFieldsInitialization$() {
const formElementControls: ReplaySubject<void>[] = this.dynamicFormFields.map(
(dynamicFormField) => dynamicFormField.componentOrControlInitFinished
);
// Use of([]) as fallback for empty form element controls
return formElementControls.length === 0
? of([])
: combineLatest(formElementControls).pipe(take(1));
}
private setupFormChangesSubscriptions(): void {
this.form.valueChanges
.pipe(
takeUntil(combineLatest([this.tearDownFieldChangesSubscriptions$, this.tearDownForm$])),
distinctUntilChanged(isEqual)
)
.subscribe(() => this.formRawValue.set(this.form.getRawValue()));
this.form.statusChanges
.pipe(
takeUntil(combineLatest([this.tearDownFieldChangesSubscriptions$, this.tearDownForm$])),
distinctUntilChanged()
)
.subscribe(() => {
const formValid = this.form.valid || this.form.disabled;
this.formValid.emit(formValid);
});
}
private enrichConfigWithAclTags(fields: DfBaseConfig[]): EnrichedFormFieldConfig[] {
let lineBreakCounter = 0;
return fields.map((field): EnrichedFormFieldConfig => {
if (field.type === 'LINE_BREAK') {
return [field, `line-break-${lineBreakCounter++}`];
}
return [field, dasherize(field.id)];
});
}
/**
* This handles the ACL setup for the formGroup.
* The autoFormBindingFactory automatically subscribes to the formGroup and reacts to changes of it.
*/
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;
const aclFormBinding = autoFormBindingFactory({})(wrappedAclService, this.form);
aclFormBinding.stream$.pipe(takeUntil(this.tearDownForm$)).subscribe();
}
private initAcl(config: BaseDynamicFormConfiguration) {
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('');
// Inject the ACL rules
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);
}
private checkDuplicatedFields(newFormConfig: BaseDynamicFormConfiguration) {
newFormConfig.fields.forEach((field, index) => {
if (newFormConfig.fields.findIndex((f) => f.id === field.id) !== index) {
throw new Error(
`Dynamic Form: The form configuration contains duplicated field IDs: "${field.id}". Field IDs must be unique.`
);
}
});
}
determineLayout(formFieldConfig?: BaseDynamicFormConfiguration<DfBaseConfig>) {
if (formFieldConfig) {
const { layout, fields } = formFieldConfig;
if (layout?.type) {
this.containerLayout = this.determineContainerLayout(layout);
this.columnClassList = this.getColumnClassList(layout, fields);
} else {
this.containerLayout = ContainerLayout.SingleColumn;
this.columnClassList = this.getColumnClassList(
{ type: DfFormLayoutType.OneColumn },
fields
);
}
}
}
private determineContainerLayout(layout: DfFormLayout) {
const layoutType = layout.type;
if (layoutType === DfFormLayoutType.OneColumn) {
return ContainerLayout.SingleColumn;
}
if (layoutType === DfFormLayoutType.CustomColumn) {
return ContainerLayout.CustomColumn;
}
return ContainerLayout.MultiColumn;
}
getColumnClassList(layout: DfFormLayout, config: DfBaseConfig[]) {
return config.map((config) => {
const type = config.type;
const FULL_WIDTH_TYPES = new Set([
'CIRCLE_TOGGLE_GROUP',
'RADIO',
'TOGGLE_BUTTON',
'NOTIFICATION'
]);
if (type === 'LINE_BREAK') {
return `line-break-element`;
}
const columnClass = 'column';
const isFullWidthComponent =
(type === 'HEADLINE' && !layout.headlineWidthMatchesLayoutType) ||
(type === 'PARAGRAPH' && !layout.paragraphWidthMatchesLayoutType) ||
FULL_WIDTH_TYPES.has(type);
if (isFullWidthComponent) {
const fullWidthClass =
layout.type === DfFormLayoutType.CustomColumn ? 'column-12' : 'one-column-element';
return `${columnClass} ${fullWidthClass}`;
}
if (layout.type !== DfFormLayoutType.CustomColumn) {
return `${columnClass} ${DfFormLayoutClassName[layout.type]}`;
}
const isCenterInRetail =
this.isRetailChannel &&
'layout' in config &&
(config as SingleInputFieldLayout)?.layout?.centerAlignInRetail;
return `${columnClass} column-${config.columnSpan ?? 12}${isCenterInRetail ? '-center' : ''}`;
});
}
ngOnDestroy(): void {
this.tearDownForm$.next();
this.tearDownFieldChangesSubscriptions$.next();
this.aclService.removeDynamicFormRules(this.id());
this.form.removeValidators(this.formLoadingValidator);
this.form.updateValueAndValidity();
}
private withoutConsecutiveLineBreakFields(fields: DfBaseConfig[]): DfBaseConfig[] {
return fields.filter((field, index) => {
if (field.type === 'LINE_BREAK') {
return fields[index - 1]?.type !== 'LINE_BREAK';
}
return true;
});
}
}