packages/form-plugin/src/directive.ts

Summary

Maintainability
A
2 hrs
Test Coverage
A
90%
import { ChangeDetectorRef, Directive, inject, Input, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormGroupDirective } from '@angular/forms';
import { Actions, ofActionDispatched, Store } from '@ngxs/store';
import { getValue } from '@ngxs/store/plugins';
import { Observable, ReplaySubject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, takeUntil } from 'rxjs/operators';
import {
  ResetForm,
  UpdateForm,
  UpdateFormDirty,
  UpdateFormErrors,
  UpdateFormStatus,
  UpdateFormValue
} from './actions';

@Directive({ selector: '[ngxsForm]', standalone: true })
export class NgxsFormDirective implements OnInit, OnDestroy {
  @Input('ngxsForm')
  path: string = null!;

  @Input('ngxsFormDebounce')
  set debounce(debounce: string | number) {
    this._debounce = Number(debounce);
  }
  get debounce() {
    return this._debounce;
  }
  private _debounce = 100;

  @Input('ngxsFormClearOnDestroy')
  set clearDestroy(val: boolean) {
    this._clearDestroy = val != null && `${val}` !== 'false';
  }
  get clearDestroy(): boolean {
    return this._clearDestroy;
  }
  private _clearDestroy = false;

  private _updating = false;

  private _actions$ = inject(Actions);
  private _store = inject(Store);
  private _formGroupDirective = inject(FormGroupDirective);
  private _cd = inject(ChangeDetectorRef);

  private readonly _destroy$ = new ReplaySubject<void>(1);

  ngOnInit() {
    this._actions$
      .pipe(
        ofActionDispatched(ResetForm),
        filter((action: ResetForm) => action.payload.path === this.path),
        takeUntil(this._destroy$)
      )
      .subscribe(({ payload: { value } }: ResetForm) => {
        this.form.reset(value);
        this.updateFormStateWithRawValue(true);
        this._cd.markForCheck();
      });

    this.getStateStream(`${this.path}.model`).subscribe(model => {
      if (this._updating || !model) {
        return;
      }

      this.form.patchValue(model);
      this._cd.markForCheck();
    });

    this.getStateStream(`${this.path}.dirty`).subscribe(dirty => {
      if (this.form.dirty === dirty || typeof dirty !== 'boolean') {
        return;
      }

      if (dirty) {
        this.form.markAsDirty();
      } else {
        this.form.markAsPristine();
      }

      this._cd.markForCheck();
    });

    // On first state change, sync form model, status and dirty with state
    this._store
      .selectOnce(state => getValue(state, this.path))
      .subscribe(() => {
        this._store.dispatch([
          new UpdateFormValue({
            path: this.path,
            value: this.form.getRawValue()
          }),
          new UpdateFormStatus({
            path: this.path,
            status: this.form.status
          }),
          new UpdateFormDirty({
            path: this.path,
            dirty: this.form.dirty
          })
        ]);
      });

    this.getStateStream(`${this.path}.disabled`).subscribe(disabled => {
      if (this.form.disabled === disabled || typeof disabled !== 'boolean') {
        return;
      }

      if (disabled) {
        this.form.disable();
      } else {
        this.form.enable();
      }

      this._cd.markForCheck();
    });

    this._formGroupDirective
      .valueChanges!.pipe(
        distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
        this.debounceChange()
      )
      .subscribe(() => {
        this.updateFormStateWithRawValue();
      });

    this._formGroupDirective
      .statusChanges!.pipe(distinctUntilChanged(), this.debounceChange())
      .subscribe((status: string) => {
        this._store.dispatch(
          new UpdateFormStatus({
            status,
            path: this.path
          })
        );
      });
  }

  updateFormStateWithRawValue(withFormStatus?: boolean) {
    if (this._updating) return;

    const value = this._formGroupDirective.control.getRawValue();

    const actions: any[] = [
      new UpdateFormValue({
        path: this.path,
        value
      }),
      new UpdateFormDirty({
        path: this.path,
        dirty: this._formGroupDirective.dirty
      }),
      new UpdateFormErrors({
        path: this.path,
        errors: this._formGroupDirective.errors
      })
    ];

    if (withFormStatus) {
      actions.push(
        new UpdateFormStatus({
          path: this.path,
          status: this._formGroupDirective.status
        })
      );
    }

    this._updating = true;
    this._store.dispatch(actions).subscribe({
      error: () => (this._updating = false),
      complete: () => (this._updating = false)
    });
  }

  ngOnDestroy() {
    this._destroy$.next();

    if (this.clearDestroy) {
      this._store.dispatch(
        new UpdateForm({
          path: this.path,
          value: null,
          dirty: null,
          status: null,
          errors: null
        })
      );
    }
  }

  private debounceChange() {
    const skipDebounceTime =
      this._formGroupDirective.control.updateOn !== 'change' || this._debounce < 0;

    return skipDebounceTime
      ? (change: Observable<any>) => change.pipe(takeUntil(this._destroy$))
      : (change: Observable<any>) =>
          change.pipe(debounceTime(this._debounce), takeUntil(this._destroy$));
  }

  private get form(): FormGroup {
    return this._formGroupDirective.form;
  }

  private getStateStream(path: string) {
    return this._store.select(state => getValue(state, path)).pipe(takeUntil(this._destroy$));
  }
}