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 {
A1_INVERSE,
CHANNEL,
CHANNEL_TOKEN,
IS_A1,
NO_MARGIN,
TalyValidationService,
ValidationConfig,
ValidationConfigItem
} from '@allianz/taly-core';
import { dasherize } from '@angular-devkit/core/src/utils/strings';
import { isPlatformServer } from '@angular/common';
import {
AfterViewChecked,
afterNextRender,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
ElementRef,
inject,
Injector,
input,
isDevMode,
model,
OnDestroy,
OnInit,
output,
PendingTasks,
PLATFORM_ID,
signal,
untracked,
viewChildren
} from '@angular/core';
import { outputFromObservable, outputToObservable, toObservable } from '@angular/core/rxjs-interop';
import { FormGroup, type UntypedFormGroup, ValidatorFn } from '@angular/forms';
import isEqual from 'lodash-es/isEqual';
import uniqueId from 'lodash-es/uniqueId';
import { combineLatest, isObservable, of, type ReplaySubject, Subject } from 'rxjs';
import { distinctUntilChanged, map, 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';
const ContainerLayout = {
SingleColumn: 'single-column',
MultiColumn: 'multi-column',
CustomColumn: 'custom-column'
} as const;
type ContainerLayout = (typeof ContainerLayout)[keyof typeof ContainerLayout];
/**
* `JSON.stringify` replacer for the render key. `JSON.stringify` would drop or flatten some values, which would
* hide a configuration change. Therefore, they are replaced by something that can be compared:
* - Observables cannot be compared, so each one is replaced by a unique id. The render key of a field with
* observables (e.g. dynamic validators) is different every time it is calculated, so that field is always rebuilt.
* - Regular expressions and functions are replaced by their source.
*/
function serializeForRenderKey(_key: string, value: unknown): unknown {
if (isObservable(value)) return uniqueId('observable-');
if (value instanceof RegExp) return `regexp:${value.toString()}`;
if (typeof value === 'function') return `function:${value.toString()}`;
return value;
}
interface EnrichedFormFieldConfig {
field: DfBaseConfig;
/** The ACL tag associated with this form field */
aclTag: string;
/**
* Identifies the rendered form field. It is used as the `track` expression of the `@for`,
* so a changed key destroys and recreates the field. See `getRenderKey()`.
*/
renderKey: string;
}
interface PreparedFormConfig {
config: BaseDynamicFormConfiguration;
fields: EnrichedFormFieldConfig[];
}
@Component({
selector: 'df-form',
templateUrl: './form.component.html',
styleUrls: ['./form.component.scss'],
host: {
'[class.rendering-in-progress]': 'hideWhileRendering()',
'[attr.data-server-rendered]': 'renderedOnServer ? "" : null'
},
standalone: false,
// This component renders dynamic form fields whose child components can be user-owned.
// It must use Eager change detection so that those components are checked properly.
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
changeDetection: ChangeDetectionStrategy.Eager
})
export class InternalDfFormComponent implements OnInit, AfterViewChecked, OnDestroy {
readonly id = input.required<string>();
readonly 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.
*/
readonly existingFormGroup = input<FormGroup | undefined>();
formValid = output<boolean>();
readonly 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.
*/
readonly transformedValidationConfiguration = input<ValidationConfigItem[]>();
protected form!: FormGroup;
protected readonly formFieldConfig = signal<EnrichedFormFieldConfig[] | undefined>(undefined);
protected readonly formFieldValidationConfigs = signal<(ValidationConfig[] | undefined)[]>([]);
protected readonly spacing = signal<DfFormfieldSpacing | undefined>(undefined);
protected readonly renderingInProgress = signal<boolean>(true);
protected readonly renderedOnServer =
isPlatformServer(inject(PLATFORM_ID)) ||
inject(ElementRef).nativeElement.hasAttribute('data-server-rendered');
private completeServerRenderingTask?: () => void;
// A server rendered form stays visible during hydration instead of being hidden by rendering-in-progress, which would make it flicker
protected readonly keepServerRenderedFormVisible = signal(this.renderedOnServer);
/**
* The form is only hidden until it was rendered for the first time. Later config updates keep the
* surviving fields visible, only the changed fields appear when they are ready.
*/
private readonly initialRenderFinished = signal(false);
protected readonly hideWhileRendering = computed(
() =>
this.renderingInProgress() &&
!this.initialRenderFinished() &&
!this.keepServerRenderedFormVisible()
);
/**
* IDs of the form fields whose component could not be loaded. Such a field has no form control, so it would not
* be part of the form validity. The form stays invalid instead, because it is missing a part of its content.
*/
private readonly failedFieldIds = computed(() =>
this.dynamicFormFields()
.filter((dynamicFormField) => dynamicFormField.componentLoadFailed())
.map((dynamicFormField) => dynamicFormField.config().id)
.filter((id): id is string => id != null)
);
private formLoadingValidator: ValidatorFn = () => {
const loading = this.renderingInProgress();
const failedFieldIds = this.failedFieldIds();
if (loading) return { formLoading: true };
return failedFieldIds.length > 0 ? { componentLoadFailed: failedFieldIds } : 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))
);
readonly containerLayout = signal<ContainerLayout>(ContainerLayout.SingleColumn);
readonly dynamicFormFields = viewChildren<DfFormfieldComponent>('dynamicFormField');
private readonly dynamicFormFields$ = toObservable(this.dynamicFormFields);
readonly columnClassList = signal<string[]>([]);
isRetailChannel = inject(CHANNEL_TOKEN) === CHANNEL.RETAIL;
isA1 = inject(IS_A1);
noMargin = Boolean(inject(NO_MARGIN, { optional: true }));
private readonly a1Inverse = inject(A1_INVERSE);
readonly inverse = computed(
() => this.isA1 && Boolean(this.a1Inverse() ?? this.formConfig()?.layout?.inverse)
);
private ignoredInverseWarningShown = false;
private tearDownForm$ = new Subject<void>();
private tearDownFieldChangesSubscriptions$ = new Subject<void>();
private injector = inject(Injector);
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
private readonly pendingTasks = inject(PendingTasks);
private elementControlsInitializationPending = false;
protected readonly formAclPath = signal<string | undefined>(undefined);
private aclService: AclService = inject(AclService);
private aclTag = inject(ACL_TAG_TOKEN, {
optional: true
});
formDataToBeRestoredAfterRendering: Record<string, unknown> | undefined = undefined;
private cdr = 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) return;
if (this.renderingInProgress()) {
// Controls of added or rebuilt fields are registered after rendering. patchValue would silently
// no-op against missing controls, so stash the value for restoreFormData to apply once they exist.
this.formDataToBeRestoredAfterRendering = formRawValue;
} else if (!isEqual(formRawValue, this.form.getRawValue())) {
this.form.patchValue(formRawValue);
}
});
// A form field that fails to load again after a retry changes the failed form fields without a new render.
// Angular does not re-run the validators by itself then, so the form validity has to be updated here.
// While rendering, the update at the end of the rendering takes care of it.
effect(() => {
this.failedFieldIds();
if (untracked(() => this.renderingInProgress())) return;
this.form.updateValueAndValidity();
});
effect(() => {
if (this.renderingInProgress()) return;
const formFieldConfig = this.formFieldConfig();
if (!formFieldConfig) {
this.formFieldValidationConfigs.set([]);
return;
}
const validationConfig = this.transformedValidationConfiguration();
if (validationConfig && validationConfig.length > 0) {
const validationMap = this.talyValidationService.applyValidationConfig(
this.form,
validationConfig,
// Controls of fields that survived the last config update still have the validators of an earlier call.
// Adding them again would duplicate the validators, so those controls are skipped.
// Fields with a changed validation are rebuilt before (see getRenderKey), so they have new controls.
{ skipAlreadyProcessedControls: true }
);
this.formFieldValidationConfigs.set(
formFieldConfig.map(({ field }) => validationMap.get(field.id))
);
}
});
effect(() => {
if (this.isA1 || this.ignoredInverseWarningShown || !isDevMode()) {
return;
}
if (this.formConfig()?.layout?.inverse) {
this.ignoredInverseWarningShown = true;
const formId = untracked(this.id);
console.warn(
`Dynamic Form "${formId}": The "layout.inverse" option is only supported with the A1 design and is ignored.`
);
}
});
// When the formConfig input changes, we update the rendered fields to match the new config.
// The validation config is part of the render key, so a change of it has to update the fields as well.
effect(() => {
this.transformedValidationConfiguration();
this.updateFormConfig(this.formConfig());
});
}
// afterNextRender never runs on the server, so the server initializes the form controls here instead
ngAfterViewChecked() {
if (!this.elementControlsInitializationPending) {
return;
}
this.elementControlsInitializationPending = false;
this.initializeElementControls();
}
ngOnInit() {
this.form = this.existingFormGroup() ?? new FormGroup({});
this.form.addValidators(this.formLoadingValidator);
this.form.updateValueAndValidity();
this.formGroupChange.emit(this.form);
}
/**
* Renders the given configuration.
* Fields whose configuration and validation did not change stay alive.
* Changed, added or removed fields are recreated or destroyed as needed.
*/
updateFormConfig(newFormConfig?: BaseDynamicFormConfiguration) {
// Everything that can throw because of an invalid config has to happen before the current form is touched.
// Otherwise, the old fields would stay on screen, but would not be connected to the form anymore.
const preparedFormConfig = this.prepareFormConfig(newFormConfig);
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.
// Fields that survive this update keep their controls and validators. The validation config is applied
// again after rendering, and skips those controls. A field with a changed validation config is rebuilt
// (see getRenderKey), so it gets a new control.
this.talyValidationService.cleanupValidationHandling(this.form);
this.renderingInProgress.set(true);
this.tearDownForm$.next();
this.completeServerRenderingTask?.();
this.completeServerRenderingTask = undefined;
if (!preparedFormConfig) {
this.formFieldConfig.set(undefined);
this.formRawValue.set(undefined);
if (newFormConfig?.fields) {
// The config has a list of fields, but it is empty
this.setupFormChangesSubscriptions();
}
this.renderingInProgress.set(false);
this.form.updateValueAndValidity();
return;
}
this.determineLayout(preparedFormConfig.config);
// The template passes the validation configs to the form fields by position, and they are updated after the
// rendering. Without this, a new form field would start with the validation config of the old one at its position.
this.formFieldValidationConfigs.set([]);
this.formFieldConfig.set(preparedFormConfig.fields);
this.initAcl(preparedFormConfig.config);
this.subscribeToElementControls();
}
/**
* Returns the cleaned up config and its enriched fields, or undefined if the config has no fields.
* Throws if the config is invalid.
*/
private prepareFormConfig(
newFormConfig?: BaseDynamicFormConfiguration
): PreparedFormConfig | undefined {
if (!newFormConfig?.fields?.length) return undefined;
const config = structuredClone({
...newFormConfig,
fields: this.withoutConsecutiveLineBreakFields(newFormConfig.fields)
});
this.checkDuplicatedFields(config);
return { config, fields: this.enrichFormFields(config.fields) };
}
/**
* Identifies a rendered form field. Any configuration change rebuilds that field, because
* components may read configuration properties only once during setup.
* The key is calculated when the form config or the validation config is updated. The validation config is
* read untracked here, because the effect that calls this already depends on it.
*/
private getRenderKey(field: DfBaseConfig, aclTag: string): string {
// The validation config is not part of the field config, but the control of a surviving field keeps the
// validators of the old config. Therefore, a changed validation config has to rebuild the field.
// It includes the validation of nested controls, whose ids are paths like `field.child`.
const validationConfig = untracked(this.transformedValidationConfiguration)?.filter(
(item) => item.id === field.id || item.id.startsWith(`${field.id}.`)
);
// Line breaks have no id, so the ACL tag keeps their keys unique.
const identity = field.type === 'LINE_BREAK' ? aclTag : field.id;
return `${identity}|${JSON.stringify([field, validationConfig], serializeForRenderKey)}`;
}
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 observing the dynamicFormFields signal query.
*/
private subscribeToElementControls() {
if (this.isServer) {
// Keeps the server from serializing the page before the form has restored its data and ACL state
this.completeServerRenderingTask = this.pendingTasks.add();
this.elementControlsInitializationPending = true;
return;
}
// afterNextRender waits for child components to mount and is automatically
// cancelled if the component is destroyed before it fires.
afterNextRender(() => this.initializeElementControls(), { injector: this.injector });
}
private initializeElementControls() {
this.waitForFieldsInitialization$()
.pipe(takeUntil(this.tearDownForm$))
.subscribe(() => {
this.setupAclBinding();
this.cdr.markForCheck();
});
// React to dynamic field visibility changes
this.dynamicFormFields$
.pipe(
tap(() => {
this.tearDownFieldChangesSubscriptions$.next();
this.form.updateValueAndValidity();
}),
tap(() => this.subscribeToFormEvents()),
switchMap(() => this.waitForFieldsInitialization$()),
takeUntil(this.tearDownForm$)
)
.subscribe(() => {
this.restoreFormData();
this.setupFormChangesSubscriptions();
this.renderingInProgress.set(false);
this.initialRenderFinished.set(true);
this.keepServerRenderedFormVisible.set(false);
this.completeServerRenderingTask?.();
this.completeServerRenderingTask = undefined;
// 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();
this.cdr.markForCheck();
});
}
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) {
// A surviving field already holds its value. Patching it again would emit a value change.
if (!control.dirty && !isEqual(control.value, value)) {
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);
this.cdr.markForCheck();
});
});
}
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);
this.cdr.markForCheck();
});
}
private enrichFormFields(fields: DfBaseConfig[]): EnrichedFormFieldConfig[] {
let lineBreakCounter = 0;
return fields.map((field): EnrichedFormFieldConfig => {
const aclTag =
field.type === 'LINE_BREAK' ? `line-break-${lineBreakCounter++}` : dasherize(field.id);
return { field, aclTag, renderKey: this.getRenderKey(field, aclTag) };
});
}
/**
* 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;
/**
* Only bind ACL for controls declared in this form's own configuration (formFieldConfig).
* This prevents nested dynamic forms from re-evaluating ACL on the parent form's controls
* with the wrong aclTag.
* If the excludes list is not set, the autoFormBindingFactory would eventually subscribe
* to all controls of the form with an aclTag that only belongs to the nested controls.
* By calculating the excludes list, we ensure that each dynamic form instance only
* binds ACL for its own controls with the correct aclTag for each one.
*/
const ownFieldIds = (this.formFieldConfig() ?? []).map(({ field }) => field.id);
const allControlKeys = Object.keys(this.form.controls);
const excludes = allControlKeys.filter((key) => !ownFieldIds.includes(key));
const aclFormBinding = autoFormBindingFactory({ excludes })(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.set(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) => {
// Line breaks have no id and are allowed to appear multiple times.
if (field.type === 'LINE_BREAK') return;
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.set(this.determineContainerLayout(layout));
this.columnClassList.set(this.getColumnClassList(layout, fields));
} else {
this.containerLayout.set(ContainerLayout.SingleColumn);
this.columnClassList.set(
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',
'CHECKBOX_GROUP',
'RADIO',
'TOGGLE_BUTTON',
'NOTIFICATION',
'TILE',
'NUMBER_STEPPER'
]);
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-full' : 'one-column-element';
return `${columnClass} ${fullWidthClass}`;
}
if (layout.type !== DfFormLayoutType.CustomColumn) {
return `${columnClass} ${DfFormLayoutClassName[layout.type]}`;
}
const isCenterInRetail =
!this.isA1 &&
this.isRetailChannel &&
'layout' in config &&
(config as SingleInputFieldLayout)?.layout?.centerAlignInRetail;
const columnSpanClass = config.columnSpan
? `column-${config.columnSpan}${isCenterInRetail ? '-center' : ''}`
: 'column-full';
return `${columnClass} ${columnSpanClass}`;
});
}
ngOnDestroy(): void {
this.completeServerRenderingTask?.();
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;
});
}
}