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

Implements

OnInit OnDestroy

Metadata

Relationships

Depends on

Index

Properties
Inputs
Outputs
HostBindings
Accessors

Constructor

constructor()

Inputs

config
Type : DfBaseConfig
Required :  true
defaultSpacing
Type : DfFormfieldSpacing
formAclPath
Type : string
inverse
Type : boolean
Default value : false
isA1
Type : boolean
Required :  true
isRetailChannel
Type : boolean
Required :  true
validationConfigs
Type : ValidationConfig[]

Outputs

formEvent
Type : DfEventPayload

HostBindings

class.df-spacing-l
Type : boolean
class.df-spacing-m
Type : boolean
class.df-spacing-none
Type : boolean
class.df-spacing-s
Type : boolean
class.df-spacing-xl
Type : boolean
class.df-spacing-xs
Type : boolean

Defines the spacing of the formfield.

class.df-spacing-xxl
Type : boolean
class.has-hidden-child
Type : any

Properties

Optional component
Type : ComponentRef<DfBaseComponent>
Readonly componentLoadFailed
Type : unknown
Default value : signal(false)
componentOrControlInitFinished
Type : unknown
Default value : new ReplaySubject<void>(1)

Additionally to the groupChanged EventEmitter a ReplaySubject is provided. In difference to the EventEmitter, this one always contains the last emitted value, which allows to check if a component was initialized after the initialization already happened. (An event will be gone at that point in time.)

This subject will fire with an undefined value, after the configuration was passed to the component, if the component doesn't have a form control.

Readonly containerRef
Type : unknown
Default value : viewChild('container', { read: ViewContainerRef })
group
Type : UntypedFormGroup
Default value : new UntypedFormGroup({})

Accessors

hasHiddenChildClass
gethasHiddenChildClass()
isSpacingXS
getisSpacingXS()

Defines the spacing of the formfield.

isSpacingS
getisSpacingS()
isSpacingM
getisSpacingM()
isSpacingL
getisSpacingL()
isSpacingXL
getisSpacingXL()
isSpacingXXL
getisSpacingXXL()
isSpacingNone
getisSpacingNone()
import { ValidationConfig } from '@allianz/taly-core';
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ComponentRef,
  effect,
  ErrorHandler,
  HostBinding,
  Injector,
  input,
  OnDestroy,
  OnInit,
  output,
  PendingTasks,
  signal,
  Signal,
  untracked,
  ViewContainerRef,
  WritableSignal,
  inject,
  viewChild
} from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { AbstractControl, FormGroupDirective, UntypedFormGroup } from '@angular/forms';
import { ReplaySubject, Subject, takeUntil } from 'rxjs';
import { DfBaseComponent } from '../base/base.component';
import { DfBaseConfig, type DfEventPayload, DfFormfieldSpacing } from '../base/base.model';
import { DfComponentLoaderService } from '../services/component-loader/component-loader.service';
import { AclExtendedFormGroup } from '@allianz/taly-acl/form-support';

interface DfHideable {
  readonly isHidden: Signal<boolean>;
}

function isHideable(obj: unknown): obj is DfHideable {
  return (
    obj != null &&
    typeof obj === 'object' &&
    'isHidden' in obj &&
    // eslint-disable-next-line @angular-eslint/no-uncalled-signals -- deliberately uncalled: checking whether `isHidden` is callable, not reading its value
    typeof (obj as DfHideable).isHidden === 'function'
  );
}

@Component({
  selector: 'df-formfield',
  templateUrl: './formfield.component.html',
  styleUrls: ['./formfield.component.scss'],
  host: {
    '[attr.data-df-id]': 'config()?.id',
    '[attr.data-df-type]': 'config()?.type'
  },
  standalone: false,
  // This component dynamically creates form field components via ViewContainerRef. It must use
  // Eager change detection so that user-made form fields are checked properly.
  // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
  changeDetection: ChangeDetectionStrategy.Eager
})
export class DfFormfieldComponent implements OnInit, OnDestroy {
  private injector = inject(Injector);
  private cdr = inject(ChangeDetectorRef);
  private componentService = inject(DfComponentLoaderService);
  private pendingTasks = inject(PendingTasks);
  private parentFormGroup = inject(FormGroupDirective);
  private errorHandler = inject(ErrorHandler);

  readonly config = input.required<DfBaseConfig>();

  private readonly componentInstance: WritableSignal<DfBaseComponent<DfBaseConfig> | undefined> =
    signal(undefined);

  private readonly hasHiddenChild = signal(false);

  @HostBinding('class.has-hidden-child')
  get hasHiddenChildClass() {
    return this.hasHiddenChild();
  }

  readonly validationConfigs = input<ValidationConfig[]>();

  readonly formAclPath = input<string>();

  readonly defaultSpacing = input<DfFormfieldSpacing>();
  readonly isRetailChannel = input.required<boolean>();
  readonly isA1 = input.required<boolean>();
  readonly inverse = input<boolean>(false);

  readonly containerRef = viewChild('container', { read: ViewContainerRef });

  readonly formEvent = output<DfEventPayload>();

  /**
   * Additionally to the groupChanged EventEmitter a ReplaySubject is provided.
   * In difference to the EventEmitter, this one always contains the last emitted value,
   * which allows to check if a component was initialized after the initialization already happened.
   * (An event will be gone at that point in time.)
   *
   * This subject will fire with an undefined value, after the configuration was passed to the component,
   * if the component doesn't have a form control.
   */
  componentOrControlInitFinished = new ReplaySubject<void>(1);

  /**
   * Defines the spacing of the formfield.
   */
  @HostBinding('class.df-spacing-xs') get isSpacingXS() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.xs);
  }

  @HostBinding('class.df-spacing-s') get isSpacingS() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.s);
  }

  @HostBinding('class.df-spacing-m') get isSpacingM() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.m);
  }

  @HostBinding('class.df-spacing-l') get isSpacingL() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.l);
  }

  @HostBinding('class.df-spacing-xl') get isSpacingXL() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.xl);
  }

  @HostBinding('class.df-spacing-xxl') get isSpacingXXL() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.xxl);
  }

  @HostBinding('class.df-spacing-none') get isSpacingNone() {
    return this.checkFormfieldSpacing(DfFormfieldSpacing.none);
  }

  private tearDownComponentSubscriptions$ = new Subject<void>();

  group: UntypedFormGroup = new UntypedFormGroup({});
  private formControl?: AbstractControl;

  component?: ComponentRef<DfBaseComponent>;

  private initialized = false;
  readonly componentLoadFailed = signal(false);

  constructor() {
    effect(() => {
      const componentInstance = this.componentInstance();
      if (componentInstance && this.validationConfigs()) {
        this.component?.setInput('validationConfigs', this.validationConfigs());
      }
    });

    effect(() => {
      const componentInstance = this.componentInstance();
      const inverse = this.inverse();
      if (componentInstance) {
        this.component?.setInput('inverse', inverse);
      }
    });

    effect(() => {
      const componentInstance = this.componentInstance();
      if (isHideable(componentInstance)) {
        this.hasHiddenChild.set(componentInstance.isHidden());
      }
    });

    // Retry a failed component load when this form field receives a new config.
    effect(() => {
      this.config();
      // Read untracked, otherwise the failed load itself would trigger this effect and retry in a loop
      const componentLoadFailed = untracked(() => this.componentLoadFailed());
      if (componentLoadFailed) {
        this.createAndInsertFormfieldComponent();
      }
    });
  }

  ngOnInit() {
    this.initialized = true;
    this.group = this.parentFormGroup.form;
    // The ngOnInit call ignores the async, so be careful what you do here:
    this.createAndInsertFormfieldComponent();
  }

  ngOnDestroy(): void {
    // Angular can destroy this component before it was initialized: The ACL handling creates and removes a
    // form field again while it replaces the ACL rules of the form. The inputs were never set in that case
    // (reading `config()` would throw), and there is nothing to clean up.
    if (!this.initialized) return;

    // Store this.config() in a variable, because it might not be available in the setTimeout callback,
    // Angular cleans up inputs shortly after ngOnDestroy completes.
    const configValue = this.config();
    // Delay the removal to prevent an ExpressionChangedAfterItHasBeenCheckedError:
    setTimeout(() => {
      this.containerRef()?.clear();

      if (
        this.formControl &&
        configValue &&
        !(this.group as AclExtendedFormGroup).__aclSyncedViewControls?.has(configValue.id)
      ) {
        // Reset the form, which also removes the data from this field from the state.
        // ACL hidden controls are not removed from the form onDestroy, in order to be able to cache them.
        this.formControl.reset();
      }

      // Remove the FormControl of this form field from the group
      // There is a catch here: Removing the control also cancels the reset event.
      // TODO: Nice to have: Find a solution that ensures that the removal is only done
      // after the reset is handled everywhere.

      // The following ensures that only the control of this component is removed from the group.
      // It is possible, that the control was replaced by another one with the same ID in the meantime.

      // This syntax "this.group.get([configValue.id])" is needed instead of this one "this.group.get(configValue.id)" because
      // otherwise the "get" function will use "." in the ids as a key separator
      if (configValue && this.group && this.group.get([configValue.id]) === this.formControl) {
        this.group.removeControl(configValue.id);
      }

      this.tearDownComponentSubscriptions$.next();
    });
  }

  private checkFormfieldSpacing(spacing: DfFormfieldSpacing): boolean {
    const configValue = this.config();
    if (configValue?.spacing) {
      return configValue.spacing === spacing;
    }
    return this.defaultSpacing() === spacing;
  }

  private safelyGetFormGroup(): UntypedFormGroup {
    if (!this.group) {
      this.group = new UntypedFormGroup({});
    }
    return this.group;
  }

  private configureComponent() {
    // Stop listening to any previously configured component
    this.tearDownComponentSubscriptions$.next();

    const componentInstance = this.component?.instance;
    this.componentInstance.set(componentInstance);
    const configValue = this.config();

    if (componentInstance && configValue) {
      // Pass down the props
      this.component?.setInput('config', configValue);
      this.component?.setInput('isRetailChannel', this.isRetailChannel());
      this.component?.setInput('isA1', this.isA1());
      this.component?.setInput('inverse', this.inverse());
      this.component?.setInput('formAclPath', this.formAclPath());

      // If the formGroup has an ACL cached control for this field, use it.
      const group = this.safelyGetFormGroup();
      if (
        group.get([configValue.id]) &&
        (group as AclExtendedFormGroup).__aclSyncedViewControls?.has(configValue.id)
      ) {
        this.component?.setInput('control', group.get([configValue.id]));
        this.component?.setInput('isAclHandled', true);
      }

      // Subscribe to changes to component's AbstractControl
      componentInstance.componentOrControlInitFinished
        .pipe(takeUntil(this.tearDownComponentSubscriptions$))
        .subscribe((control) => {
          // Using a setTimeout here prevents an ExpressionChangedAfterItHasBeenCheckedError error.
          setTimeout(() => {
            this.onFormControlChanged(control);
            this.cdr.markForCheck();
          }, 0);
        });

      outputToObservable(componentInstance.formEvent)
        .pipe(takeUntil(this.tearDownComponentSubscriptions$))
        .subscribe((event) => {
          this.formEvent.emit(event);
          this.cdr.markForCheck();
        });
    }
  }

  private onFormControlChanged = (formControl: AbstractControl | undefined) => {
    const configValue = this.config();

    if (configValue?.type === 'LINE_BREAK') {
      this.componentOrControlInitFinished.next();
      return;
    }

    if (!configValue?.id) {
      return;
    }

    this.formControl = formControl;
    const group = this.safelyGetFormGroup();
    group.setControl(configValue.id, formControl);

    this.componentOrControlInitFinished.next();
  };

  private async createAndInsertFormfieldComponent() {
    const configValue = this.config();
    if (!configValue?.type) {
      return;
    }
    await this.pendingTasks.run(async () => {
      try {
        // Create desired form field component
        const { component, moduleRef } = await this.componentService.getComponent(configValue);
        this.component = this.containerRef()?.createComponent<DfBaseComponent>(
          component as unknown as typeof DfBaseComponent,
          {
            ngModuleRef: moduleRef,
            injector: this.injector
          }
        );
        this.configureComponent();
        this.componentLoadFailed.set(false);
      } catch (error) {
        // Loading the component code or creating the component failed. Finish the initialization without a component,
        // so that one failed form field does not block the whole form.
        // The form field has no control in that case, so it is not part of the form value or the form validity.
        // It is retried when it receives a new config.
        this.tearDownComponentSubscriptions$.next();
        this.component?.destroy();
        this.component = undefined;
        this.componentInstance.set(undefined);
        this.componentLoadFailed.set(true);
        this.errorHandler.handleError(
          new Error(
            `Dynamic Form: The component of the form field "${configValue.id}" (type "${configValue.type}") could not be loaded or created`,
            { cause: error }
          )
        );
        this.componentOrControlInitFinished.next();
      }
    });
  }
}
<ng-template #container></ng-template>

./formfield.component.scss

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

:host {
  --df-formfield-spacing-desktop-default: var(
    --semantic-gap-all-linear-responsive-800,
    var(--vertical-inner-section-spacing)
  );
  --df-formfield-spacing-mobile-default: var(
    --semantic-gap-all-linear-responsive-800,
    var(--vertical-inner-section-spacing)
  );

  &:has(df-headline) {
    --df-formfield-spacing-desktop-default: 0px !important;
    --df-formfield-spacing-mobile-default: 0px !important;
  }
  display: block;
  ::ng-deep {
    // Removes NDBX formfield paddings
    --formfield-bottom-padding: 0;
    --formfield-mobile-bottom-padding: 0;

    // ng-aquila's compatibility.css applies `margin: 12px 0` to
    // .nx-error--message globally. Inside DF fields the spacing is owned by the
    // formfield resp. the Aquila component itself, so only the compatibility
    // layer's margin must be neutralized here.
    //
    // The target is wrapped in :where() so this rule keeps the specificity of
    // the :host attribute alone (0,1,0) — exactly matching compatibility.css.
    // It therefore beats compatibility.css purely by source order (component
    // styles are injected after the global stylesheet), while the components'
    // own, higher-specificity (0,2,0) margins keep applying untouched.
    :where(.nx-error--message) {
      margin: 0;
    }
  }

  &:not(:empty) {
    /**
    * Formfield components can override the default spacing by overriding the following tokens:
    * --df-formfield-spacing-desktop-default
    * --df-formfield-spacing-mobile-default
    */
    margin-bottom: var(--df-formfield-spacing-desktop-default);

    &.retail {
      margin-bottom: var(--df-formfield-spacing-desktop-default);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-default);
      }
    }

    &.df-spacing-none {
      margin-bottom: var(--df-formfield-spacing-none, 0);
    }
    &.df-spacing-xs {
      margin-bottom: var(--df-formfield-spacing-desktop-xs, 8px);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-xs, 8px);
      }
    }
    &.df-spacing-s {
      margin-bottom: var(--df-formfield-spacing-desktop-s, 16px);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-s, 8px);
      }
    }
    &.df-spacing-m {
      margin-bottom: var(--df-formfield-spacing-desktop-m, 24px);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-m, 16px);
      }
    }
    &.df-spacing-l {
      margin-bottom: var(--df-formfield-spacing-desktop-l, 32px);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-l, 24px);
      }
    }
    &.df-spacing-xl {
      margin-bottom: var(--df-formfield-spacing-desktop-xl, 40px);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-xl, 32px);
      }
    }
    &.df-spacing-xxl {
      margin-bottom: var(--df-formfield-spacing-desktop-xxl, 48px);
      @container (max-width: #{$container-breakpoint-m}) {
        margin-bottom: var(--df-formfield-spacing-mobile-xxl, 40px);
      }
    }
  }

  &:empty,
  &.has-hidden-child {
    display: none;
  }
}

// Removes last visible formfield's margin-bottom.
::ng-deep df-form {
  // Remove margin from any visible formfield that has no visible siblings after it
  df-formfield:not(:empty):not(.has-hidden-child):not(:has(~ df-formfield:not(.has-hidden-child))) {
    margin-bottom: 0 !important;
  }

  df-formfield:last-of-type {
    margin-bottom: 0 !important;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""