File

libs/core/dynamic-form/src/form/form.component.ts

Implements

OnInit OnDestroy

Metadata

Index

Properties
Methods

Constructor

constructor()

Methods

determineLayout
determineLayout(layout?: DfFormLayout)
Parameters :
Name Type Optional
layout DfFormLayout Yes
Returns : void
getColumnClassList
getColumnClassList(layout: DfFormLayout, config: DfBaseConfig[])
Parameters :
Name Type Optional
layout DfFormLayout No
config DfBaseConfig[] No
Returns : any
updateFormConfig
updateFormConfig(newFormConfig?: BaseDynamicFormConfiguration)

This tears down the old form and renders a new one, if a configuration is given

Parameters :
Name Type Optional
newFormConfig BaseDynamicFormConfiguration Yes
Returns : void

Properties

columnClassList
Type : string[]
Default value : []
containerLayout
Type : ContainerLayout
Default value : ContainerLayout.SingleColumn
dynamicFormFields
Type : QueryList<DfFormfieldComponent>
Decorators :
@ViewChildren('dynamicFormField')
enrichedValidationConfiguration
Default value : input<ValidationConfigItem[]>()
  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.

existingFormGroup
Default value : input<FormGroup | 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.

formConfig
Default value : input<BaseDynamicFormConfiguration | undefined>(undefined)
formDataToBeRestoredAfterRendering
Type : Record<string | > | undefined
Default value : undefined
Readonly formEvent
Default value : output<DfEventPayload>()
formGroupChange
Default value : output<UntypedFormGroup>()
formInitFinished
Default value : outputFromObservable( toObservable(this.renderingInProgress).pipe(map((renderingInProgress) => !renderingInProgress)) )

formInitFinished will be triggered, once all components within the dynamic form have finished their initialization.

formRawValue
Default value : model<Record<string, unknown> | undefined>(undefined)
formValid
Default value : output<boolean>()
id
Default value : input.required<string>()
isRetailChannel
Default value : inject(CHANNEL_TOKEN) === CHANNEL.RETAIL
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;
    });
  }
}
@if (formFieldConfig && form) {
<form [ngClass]="containerLayout" [formGroup]="form">
  @for (fieldConfig of formFieldConfig; track fieldConfig.id; let i = $index) {
  <!---->
  <df-formfield
    #dynamicFormField
    [attr.data-render-name]="fieldConfig.renderName"
    [config]="fieldConfig"
    [validationConfigs]="formFieldValidationConfigs[i]"
    [className]="columnClassList[i]"
    [ngClass]="{ retail: isRetailChannel, hidden: formFieldsHiddenStatus.get(fieldConfig.id) }"
    [defaultSpacing]="spacing"
    [isRetailChannel]="isRetailChannel"
    [formAclPath]="formAclPath"
  ></df-formfield>
  }
</form>
}

./form.component.scss

@use '../breakpoints.scss' as *;

:host {
  display: block;
}

form.single-column {
  display: block;
}

form.multi-column,
form.custom-column {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: 32px;

  @media (max-width: $breakpoint-m) {
    display: block;
  }
}

form.multi-column > .two-column-element {
  grid-column: span 6;
  width: 100%;
}

form.multi-column > .three-column-element {
  grid-column: span 4;
  width: 100%;
}

form.multi-column > .four-column-element {
  grid-column: span 3;
  width: 100%;
}

form > .line-break-element {
  grid-column-end: -1;
}

form.custom-column {
  @for $i from 1 through 12 {
    .column-#{$i} {
      grid-column: span #{$i};
      width: 100%;
    }
  }
}

df-formfield.hidden {
  display: none;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""