File

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

Index

Properties

Properties

isHidden
Type Signal<boolean>
import { ValidationConfig } from '@allianz/taly-core';
import {
  Component,
  ComponentRef,
  effect,
  HostBinding,
  Injector,
  input,
  OnDestroy,
  OnInit,
  output,
  signal,
  Signal,
  ViewChild,
  ViewContainerRef,
  WritableSignal,
  inject
} 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 &&
    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
})
export class DfFormfieldComponent implements OnInit, OnDestroy {
  private injector = inject(Injector);
  private componentService = inject(DfComponentLoaderService);
  private parentFormGroup = inject(FormGroupDirective);

  config = input.required<DfBaseConfig>();

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

  private hasHiddenChild = signal(false);

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

  validationConfigs = input<ValidationConfig[]>();

  formAclPath = input<string>();

  defaultSpacing = input<DfFormfieldSpacing>();
  isRetailChannel = input.required<boolean>();

  @ViewChild('container', { read: ViewContainerRef, static: true }) containerRef?: 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>;

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

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

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

  ngOnDestroy(): void {
    // 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 old FormControl from 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('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);
          }, 0);
        });

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

  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;
    }
    // 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();
  }
}

results matching ""

    No results matching ""