Search code examples
javascriptangularformsdrag-and-drop

Angular FormGroup values not updating in DOM with patchValue()


I have a FiltersAccordion component to manage table filtering. This component is pretty complex due to the fact that the users must be able to group filters in AND & OR groups. My current file structure is:

  • FiltersAccordion: manages the overall "save filters" logic.
  • FiltersBlock: displays a list of filters grouped by an AND.
  • Filter: each one of the filters, consists of one or more inputs/selects/checkbox.

The goal is that each one of those Filters can be drag&dropped from a FilterBlock to another one. The main problem right now is that the "new filter" added to the drop destination block retains all the correct data, but is not reflecting it in the form values in the DOM.

This is the code for the FilterBlock component. Here filterGroups is an array of FormGroups which is looped in the <ul>:

// filters-block.component.html

<ul
  class="filter__group__list"
  (dragover)="handleDragOver($event)"
  (drop)="handleDrop($event)"
>
  <li
    class="filter__group__item"
    draggable="true"
    *ngFor="let group of filterGroups; let idx = index"
  >
    <app-filter
      (change)="handleUpdateFilter($event, idx)"
      [columnsService]="columnsService"
      [data]="data"
      [defaultValues]="group"
      (dragStart)="handleDragStart($event, idx)"
      (dragEnd)="handleDragEnd($event, idx)"
      (removeFilter)="handleRemoveFilter(idx)"
    ></app-filter>
  </li>
</ul>
// filters-block.component.ts

// * Filters grouped by AND operator
export class FiltersBlockComponent implements OnInit {
  @Output() dragStart = new EventEmitter<{ event: DragEvent; item: number }>();
  @Output() dragEnd = new EventEmitter<{ event: DragEvent; item: number }>();
  @Output() removeBlock = new EventEmitter<void>();

  public filterGroups: FormGroup<FilterGroupTO>[];

  constructor() {}

  ngOnInit() {
    this.filterGroups = [
      new FormGroup<FilterFormGroupTO>({
        checkBox: new FormControl<boolean | null>(false),
        field: new FormControl<FilterableColumnsTO | null>(null),
        relation: new FormControl<string | null>(''),
        value: new FormControl<FilterableColumnsTO | null>(null),
      }),
    ];
  }

  handleUpdateFilter(filter: FilterFormGroupTO, index: number) {
    this.filterGroups[index].patchValue(filter as any);
  }

  handleRemoveFilter(index: number) {
    this.filterGroups.splice(index, 1);

    if (this.filterGroups.length === 0) {
      this.removeBlock.emit();
    }
  }

  handleDragStart(event: DragEvent, index: number) {
    this.dragStart.emit({ event, item: index });
  }

  handleDragEnd(event: DragEvent, index: number) {
    this.dragEnd.emit({ event, item: index });
  }

  handleDragOver(event: DragEvent) {
    event.preventDefault();
    if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
  }

  handleDrop(event: DragEvent) {
    event.preventDefault();
    if (event.dataTransfer) {
      const filterData = event.dataTransfer.getData('filter');
      const properFilter = JSON.parse(filterData);

      const newGroup = new FormGroup<FilterFormGroupTO>({
        checkBox: new FormControl<boolean | null>(properFilter.checkBox),
        field: new FormControl<FilterableColumnsTO | null>(properFilter.field),
        relation: new FormControl<string | null>(properFilter.relation),
        value: new FormControl<FilterableColumnsTO | null>(properFilter.value),
      });
      this.filterGroups.push(newGroup);
    }
  }
}

The Filter component contains all the form logic and inputs:

<div
  [formGroup]="filterFormGroup"
  class="filter__group__item"
  [draggable]="enableDrag"
  (dragstart)="handleDragStart($event)"
  (dragend)="handleDragEnd($event)"
>
      <select
        formControlName="field"
      >
        <option
          *ngFor="let option of filtersColumns"
          [displayValue]="option.fieldName"
          [readValue]="option"
          >{{ option.fieldName }}</option
        >
      </select>
      <select
        formControlName="relation"
      >
        <option
          *ngFor="
            let option of filterFormGroup.controls['field'].value?.operations;
            let i = index
          "
          [readValue]="option"
          >{{
            "DIALOGS.FILTERS.RELATION." + option | translate
          }}</option
        >
      </select>
      <select
        formControlName="value"
      >
        <option
          *ngFor="let option of fieldDistincValues; let i = index"
          [displayValue]="option"
          [readValue]="option"
          >{{ option }}</option
        >
      </select>
      <input
        formControlName="value"
        type="text"
      ></input>
    </div>
    <app-toggle
      formControlName="checkBox"
      [label]="'DIALOGS.FILTERS.CHECKBOX' | translate"
    ></app-toggle>
</div>
// filter.component.ts

export interface FilterFormGroupTO {
  field: FormControl<FilterableColumnsTO | null>;
  relation: FormControl<string | null>;
  value: FormControl<FilterableColumnsTO | null>;
  checkBox: FormControl<boolean | null>;
}

export class FilterComponent implements OnInit {
  @Output() change = new EventEmitter<FilterFormGroupTO>();
  @Output() dragStart = new EventEmitter<DragEvent>();
  @Output() dragEnd = new EventEmitter<DragEvent>();
  @Output() removeFilter = new EventEmitter<void>();
  @Input() defaultValues: FormGroup<FilterFormGroupTO>;

  // filters
  private selectedFilters: FilterTO[] = [];
  public availableFilters: Columns[] = [];

  // form
  public filterFormGroup: FormGroup<FilterFormGroupTO>;

  constructor(filtersService: FiltersService) {}

  ngOnInit() {
    // Initialize form. NOT WORKING
    this.filterFormGroup = new FormGroup<FilterFormGroupTO>({
      checkBox: new FormControl<boolean | null>(
        this.defaultValues.value.checkBox as boolean | null
      ),
      field: new FormControl<FilterableColumnsTO | null>(
        this.defaultValues.value.field as FilterableColumnsTO | null
      ),
      relation: new FormControl<string | null>(
        this.defaultValues.value.relation as string | null
      ),
      value: new FormControl<FilterableColumnsTO | null>(
        this.defaultValues.value.value as FilterableColumnsTO | null
      ),
    });

    // Patch form values. NOT WORKING
    this.filterFormGroup.patchValues({
      checkBox: this.defaultValues.value.checkBox as boolean | null,
      field: this.defaultValues.value.field as FilterableColumnsTO | null,
      relation: this.defaultValues.value.relation as string | null,
      value: this.defaultValues.value.value as FilterableColumnsTO | null,
    });

    // Get available filters
    filtersService().subscribe((res) => {
      this.availableFilters = res;
    });

    // Changes in form listener
    this.filterFormGroup.valueChanges.subscribe((value) => {
      this.change.emit(value as unknown as FilterFormGroupTO);
    });
  }

  handleRemoveFilter() {
    this.removeFilter.emit();
  }

  handleDragStart(event: DragEvent) {
    const fieldValue = this.filterFormGroup.value['field'];
    const checkboxValue = Boolean(this.filterFormGroup.value['checkBox']);
    const relationValue = this.filterFormGroup.value['relation'];
    const valueValue = this.filterFormGroup.value['value'];

    const data = {
      checkBox: checkboxValue,
      field: fieldValue,
      relation: relationValue,
      value: valueValue,
    };

    if (this.enableDrag) {
      event.dataTransfer?.setData('filter', JSON.stringify(data));

      if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
      this.dragStart.emit(event);
    }
  }

  handleDragEnd(event: DragEvent) {
    if (this.enableDrag) {
      this.dragEnd.emit(event);

      if (event.dataTransfer?.dropEffect === 'move') {
        this.handleRemoveFilter();
      }
    }
  }
}

As I pointed out, the ngOnInit of the Filter component does does carry the correct data when I log it. Even when I console.log(this.filterFormGroup) I'm getting the correct values. Why are they not being rendered in the DOM?

Am I approaching this the wrong way? I'm new to Angular forms and this is the best I could manage. Thanks.


Solution

  • Since you mentioned that FilterComponent is the child component and the form is not being initialized when a filter is dragged & dropped, that's because the form creation you have is handled on ngOnInit lifecycle hook.

    You have to keep in mind that this hook runs exactly once, if there are further changes coming to the child component from a parent component and you want to handle some logic (in this case, re-populate the form), ngOnInit will not care.

    There are multiple ways to handle this, one way could be using ngOnChanges along with ChangeStrategy.onPush like the following example:

    @Component({
      ...,
      changeDetection: ChangeDetectionStrategy.OnPush
    })
    export class ChildComponent implements OnInit, OnChanges {
       @Input() userData!: User;
       protected form!: FormGroup<UserForm>();   
    
       ngOnInit() {
        // some other logic...
       }
    
       ngOnChanges() {
         this._initForm();
       }
    
       private _initForm(): void {
          this.form = new FormGroup<UserForm>({
             name = new FormControl<string | null>(this.userData?.name || null),
             address = new FormControl<string | null>(this.userData?.addr || null)
          })
       }
    }
    

    Each time userData is updated with new values (even the first time), ngOnChanges will detect it and you can handle the form creation and initialization.

    This approach is usually used for a master/detail UI design (at least on my experience) and I think is the same design that you have for your FilterBlock/Filter.

    Demo