File

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

Index

Properties

Properties

isHidden
Type Signal<boolean>
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();
      }
    });
  }
}

results matching ""

    No results matching ""