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

Implements

OnInit AfterViewChecked OnDestroy

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

existingFormGroup
Type : 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
Type : BaseDynamicFormConfiguration | undefined
Default value : undefined
formRawValue
Type : Record<string, unknown> | undefined
Default value : undefined
id
Type : string
Required :  true
transformedValidationConfiguration
Type : 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.

Outputs

formEvent
Type : DfEventPayload
formGroupChange
Type : UntypedFormGroup
formRawValue
Type : Record<string, unknown> | undefined
formValid
Type : boolean

Methods

determineLayout
determineLayout(formFieldConfig?: BaseDynamicFormConfiguration<DfBaseConfig>)
Parameters :
Name Type Optional
formFieldConfig BaseDynamicFormConfiguration<DfBaseConfig> 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)

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.

Parameters :
Name Type Optional
newFormConfig BaseDynamicFormConfiguration Yes
Returns : void

Properties

Readonly columnClassList
Type : unknown
Default value : signal<string[]>([])
Readonly containerLayout
Type : unknown
Default value : signal<ContainerLayout>(ContainerLayout.SingleColumn)
Readonly dynamicFormFields
Type : unknown
Default value : viewChildren<DfFormfieldComponent>('dynamicFormField')
formDataToBeRestoredAfterRendering
Type : Record<string | unknown> | undefined
Default value : undefined
formInitFinished
Type : unknown
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.

Readonly inverse
Type : unknown
Default value : computed( () => this.isA1 && Boolean(this.a1Inverse() ?? this.formConfig()?.layout?.inverse) )
isA1
Type : unknown
Default value : inject(IS_A1)
isRetailChannel
Type : unknown
Default value : inject(CHANNEL_TOKEN) === CHANNEL.RETAIL
noMargin
Type : unknown
Default value : Boolean(inject(NO_MARGIN, { optional: true }))
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;
    });
  }
}
@if (formFieldConfig() && form) {
<form
  [ngClass]="containerLayout()"
  [class.inverse]="inverse()"
  [class.no-margin]="noMargin"
  [formGroup]="form"
  [nxSurface]="inverse() ? 'attention' : 'inherit'"
  [nxSurfaceBackground]="inverse()"
>
  @for (fieldConfig of formFieldConfig(); track fieldConfig.renderKey; let i = $index) {
  <!---->
  <df-formfield
    #dynamicFormField
    *aclTag="fieldConfig.aclTag"
    [attr.data-render-name]="fieldConfig.field.renderName"
    [config]="fieldConfig.field"
    [validationConfigs]="formFieldValidationConfigs()[i]"
    [className]="columnClassList()[i]"
    [ngClass]="{ retail: isRetailChannel }"
    [defaultSpacing]="spacing()"
    [isRetailChannel]="isRetailChannel"
    [isA1]="isA1"
    [inverse]="inverse()"
    [formAclPath]="formAclPath()"
  ></df-formfield>
  }
</form>
}

./form.component.scss

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

$columns-gap: 32px;
$custom-columns: clamp(2, var(--df-custom-columns, 12), 12);

:host {
  display: block;
}

:host(.rendering-in-progress) {
  // Why is this visibility: hidden and height 0 approach used here?
  // Because display: none caused a change detection endless loop:

  // 1. The textarea component got rendered, which activates a resizeToFitContent functionality. It is called on every change detection cycle in ngDoCheck.
  // This functionality creates a clone of the textarea DOM element and adds it to the DOM to measure the height. This measurement always results in 0 with display: none.

  // 2. The added clone of the textarea in the DOM is detected by the MutationObserver of the TALY frame. This one triggers a change detection cycle.

  // 3. The change detection cycle triggers the resizeToFitContent functionality of the textarea again. As the previous measurement was stored as 0,
  // it tries to do another one and adds another clone of the textarea to the DOM.

  // 4. This new clone is detected by the MutationObserver of the TALY frame, which triggers another change detection cycle.

  // 5. And from hereon out they go into an eternal loop of adding clones and triggering change detection cycles, until a brave developer steps in and
  // kills the browser process.
  visibility: hidden;
  height: 0;
  overflow: hidden;
}

form.inverse:not(.no-margin) {
  padding: var(--semantic-inset-all-surface-responsive-1000, 40px);
  border-radius: var(--semantic-border-radius-m, 8px);
}

form.single-column {
  display: block;
}

form.multi-column,
form.custom-column {
  display: grid;
  column-gap: $columns-gap;

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

form.custom-column {
  grid-template-columns: repeat(#{$custom-columns}, minmax(0, 1fr));
}

form.multi-column {
  grid-template-columns: repeat(12, minmax(0, 1fr));
}

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

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 {
  .column-full {
    grid-column: 1 / -1;
    width: 100%;
  }

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

    .column-#{$i}-center {
      $span: $i * 2;
      grid-column: 1 / -1;
      display: grid;
      grid-template-columns: repeat(calc(2 * #{$custom-columns}), minmax(0, 1fr));
      column-gap: $columns-gap;

      ::ng-deep > * {
        grid-column: calc(#{$custom-columns} - #{$i} + 1) / span #{$span};

        @container (max-width: #{$container-breakpoint-m}) {
          grid-column: 1 / -1;
        }
      }
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""