File

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

Implements

OnInit OnDestroy

Metadata

Index

Properties
HostBindings
Accessors

Constructor

constructor(injector: Injector, componentService: DfComponentLoaderService, parentFormGroup: FormGroupDirective)
Parameters :
Name Type Optional
injector Injector No
componentService DfComponentLoaderService No
parentFormGroup FormGroupDirective No

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

Properties

Optional component
Type : ComponentRef<DfBaseComponent>
componentOrControlInitFinished
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.

config
Default value : input<DfBaseConfig>()
Optional containerRef
Type : ViewContainerRef
Decorators :
@ViewChild('container', {read: ViewContainerRef, static: true})
defaultSpacing
Default value : input<DfFormfieldSpacing>()
formAclPath
Default value : input<string>()
Readonly formEvent
Default value : output<DfEventPayload>()
group
Type : UntypedFormGroup
Default value : new UntypedFormGroup({})
Readonly groupChanged
Default value : output<UntypedFormGroup>()
isRetailChannel
Default value : input.required<boolean>()
validationConfigs
Default value : input<ValidationConfig[]>()

Accessors

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 {
  Component,
  ComponentRef,
  effect,
  HostBinding,
  Injector,
  input,
  OnDestroy,
  OnInit,
  output,
  signal,
  ViewChild,
  ViewContainerRef,
  WritableSignal
} 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';

@Component({
  selector: 'df-formfield',
  templateUrl: './formfield.component.html',
  styleUrls: ['./formfield.component.scss'],
  standalone: false
})
export class DfFormfieldComponent implements OnInit, OnDestroy {
  config = input<DfBaseConfig>();

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

  validationConfigs = input<ValidationConfig[]>();

  formAclPath = input<string>();

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

  readonly groupChanged = output<UntypedFormGroup>();

  @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(
    private injector: Injector,
    private componentService: DfComponentLoaderService,
    private parentFormGroup: FormGroupDirective
  ) {
    effect(() => {
      const componentInstance = this.componentInstance();
      if (componentInstance && this.validationConfigs()) {
        this.component?.setInput('validationConfigs', this.validationConfigs());
      }
    });
  }

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

  ngOnDestroy(): void {
    // Delay the removal to prevent an ExpressionChangedAfterItHasBeenCheckedError:
    setTimeout(() => {
      this.containerRef?.clear();

      if (this.formControl) {
        // Reset the form, which also removes the data from this field from the state.
        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
      const configValue = this.config();
      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());

      // Subscribe to changes to component's AbstractControl
      outputToObservable(componentInstance.controlChanged)
        .pipe(takeUntil(this.tearDownComponentSubscriptions$))
        .subscribe((control) => {
          // The controlChanged output was an asynchronous EventEmitter in the past. With the migration
          // to an output function, this changed to be synchronous, which lead to an ExpressionChangedAfterItHasBeenCheckedError.
          // Using a setTimeout here ensures that the behavior is the same as with the previous asynchronous EventEmitter.
          setTimeout(() => {
            this.onFormControlChanged(control);
          }, 0);
        });

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

      // If the component doesn't implement a control, fire the componentOrControlInitFinished
      // to tell others, that the component is finished. But if the component decided to use the deferSetupControl, we wait for it to finish.
      if (!componentInstance.control()) {
        if (componentInstance.deferSetupControl) {
          componentInstance.componentOrControlInitFinished
            .pipe(takeUntil(this.tearDownComponentSubscriptions$))
            .subscribe(this.componentOrControlInitFinished);
        } else {
          this.componentOrControlInitFinished.next();
        }
      }
    }
  }

  private onFormControlChanged = (formControl: AbstractControl | undefined) => {
    const configValue = this.config();
    if (!configValue?.id) {
      return;
    }
    this.formControl = formControl;
    const group = this.safelyGetFormGroup();

    if (group.get(configValue.id)) {
      throw new Error(
        `Dynamic Form: The form control with the ID "${configValue.id}" already exists in the the formGroup.`
      );
    }

    group.setControl(configValue.id, formControl);

    this.groupChanged.emit(group);
  };

  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();
  }
}
<ng-template #container></ng-template>

./formfield.component.scss

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

:host {
  --df-formfield-spacing-desktop-default: var(--vertical-inner-section-spacing);
  --df-formfield-spacing-mobile-default: var(--vertical-inner-section-spacing);

  &:has(df-headline) {
    --df-formfield-spacing-desktop-default: 0px !important;
  }
  display: block;
  ::ng-deep {
    // Removes NDBX formfield paddings
    --formfield-bottom-padding: 0;
    --formfield-mobile-bottom-padding: 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);
      @media (max-width: $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);
      @media (max-width: $breakpoint-m) {
        margin-bottom: var(--df-formfield-spacing-mobile-xs, 8px);
      }
    }
    &.df-spacing-s {
      margin-bottom: var(--df-formfield-spacing-desktop-s, 16px);
      @media (max-width: $breakpoint-m) {
        margin-bottom: var(--df-formfield-spacing-mobile-s, 8px);
      }
    }
    &.df-spacing-m {
      margin-bottom: var(--df-formfield-spacing-desktop-m, 24px);
      @media (max-width: $breakpoint-m) {
        margin-bottom: var(--df-formfield-spacing-mobile-m, 16px);
      }
    }
    &.df-spacing-l {
      margin-bottom: var(--df-formfield-spacing-desktop-l, 32px);
      @media (max-width: $breakpoint-m) {
        margin-bottom: var(--df-formfield-spacing-mobile-l, 24px);
      }
    }
    &.df-spacing-xl {
      margin-bottom: var(--df-formfield-spacing-desktop-xl, 40px);
      @media (max-width: $breakpoint-m) {
        margin-bottom: var(--df-formfield-spacing-mobile-xl, 32px);
      }
    }
    &.df-spacing-xxl {
      margin-bottom: var(--df-formfield-spacing-desktop-xxl, 48px);
      @media (max-width: $breakpoint-m) {
        margin-bottom: var(--df-formfield-spacing-mobile-xxl, 40px);
      }
    }
  }

  &:empty {
    display: none;
  }
}

// Removes last formfield's margin-bottom.
::ng-deep df-form {
  df-formfield:last-of-type {
    margin-bottom: 0 !important;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""