Search code examples
angularangular-formsangular-signals

Using signals with dynamic angular forms?


I have this shared component which builds a form based on the SearchFilterOption structure.

Here it is (simplified):

import {
    Component,
    EventEmitter,
    inject,
    Input,
    OnChanges,
    Output,
    SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, Subscription } from 'rxjs';

export type SearchFilterOption = {
    kind: 'text' | 'search' | 'checkbox';
    label: string;
    key: string;
};

@Component({
    selector: 'shared-search-filter',
    standalone: true,
    imports: [CommonModule, ReactiveFormsModule],
    templateUrl: './search-filter.component.html',
})
export class SearchFilterComponent implements OnChanges {
    @Input() filter: { [key: string]: any } = {};
    @Input() filterOptions: readonly SearchFilterOption[] = [];
    @Output() filterChange = new EventEmitter<{ [key: string]: any }>();

    form = new FormGroup({});
    formChange?: Subscription;

    ngOnChanges(changes: SimpleChanges): void {
        if (changes['filterOptions']) {
            const group: { [key: string]: FormControl } = {};
            for (const filter of this.filterOptions) {
                group[filter.key] = new FormControl();
            }
            this.form = new FormGroup(group);
            this.formChange?.unsubscribe();
            this.formChange = this.form.valueChanges
                .pipe(
                    debounceTime(500),
                    distinctUntilChanged(
                        (a, b) => JSON.stringify(a) === JSON.stringify(b)
                    )
                )
                .subscribe({
                    next: (values) => {
                        this.filterChange.emit(values);
                    },
                });
        }
        if (changes['filter']) {
            for (const key of Object.keys(this.filter)) {
                const ctrl = this.form.get(key);
                if (ctrl) {
                    ctrl.setValue(this.filter[key]);
                }
            }
        }
    }
}
<fieldset [formGroup]="form">
    @for (option of filterOptions; track option; let i = $index) {
        <label>{{ option.label }}</label>
        <input [type]="option.kind" [formControlName]="option.key" />
    }
</fieldset>

and I wanted to try using signals for the input and output of this component, but it all seems to fall apart with errors of missing form fields when I do:

    filter = model<{ [key: string]: any }>({});
    filterOptions = input.required<readonly SearchFilterOption[]>();

    form = new FormGroup({});
    formChange?: Subscription;

    constructor() {
        effect(
            () => {
                const group: { [key: string]: FormControl } = {};
                for (const filter of this.filterOptions()) {
                    group[filter.key] = new FormControl(
                        this.filter()[filter.key]
                    );
                }
                this.form = new FormGroup(group);

                this.formChange?.unsubscribe();
                this.formChange = this.form.valueChanges
                    .pipe(
                        debounceTime(500),
                        distinctUntilChanged(
                            (a, b) => JSON.stringify(a) === JSON.stringify(b)
                        )
                    )
                    .subscribe({
                        next: (values) => {
                            this.filter.set(values);
                        },
                    });
            },
            { allowSignalWrites: true }
        );
    }
<fieldset [formGroup]="form">
    @for (option of filterOptions(); track option; let i = $index) {
        <label>{{ option.label }}</label>
        <input [type]="option.kind" [formControlName]="option.key" />
    }
</fieldset>

I've also tried a variation where the subscription uses toSignal, and one that uses computed to build the form and can't really figure out what else to do.

Is there a working method of doing this with signals right now?


Solution

  • Note: for now this is the best approach I can think of, but when reactive form signals arrives, this code will be redundant.


    Your code works fine, all you need to do is to move the for loop that creates the form controls to the ngOnInit.

    import {
      Component,
      effect,
      EventEmitter,
      inject,
      input,
      Input,
      model,
      OnChanges,
      Output,
      SimpleChanges,
      untracked,
    } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
    import { debounceTime, distinctUntilChanged, Subscription, tap } from 'rxjs';
    import { toSignal, outputFromObservable } from '@angular/core/rxjs-interop';
    
    export type SearchFilterOption = {
      kind: 'text' | 'search' | 'checkbox';
      label: string;
      key: string;
    };
    
    @Component({
      selector: 'shared-search-filter',
      standalone: true,
      imports: [CommonModule, ReactiveFormsModule],
      template: `<fieldset [formGroup]="form">
      @for (option of filterOptions; track option; let i = $index) {
          <label>{{ option.label }}</label>
          <input [type]="option.kind" [formControlName]="option.key" />
      }
    </fieldset>`,
    })
    export class SearchFilterComponent implements OnChanges {
      @Input() filter: { [key: string]: any } = {};
      @Input() filterOptions: readonly SearchFilterOption[] = [];
      @Output() filterChange = new EventEmitter<{ [key: string]: any }>();
    
      form = new FormGroup({});
      formChange?: Subscription;
    
      ngOnChanges(changes: SimpleChanges): void {
        if (changes['filterOptions']) {
          const group: { [key: string]: FormControl } = {};
          for (const filter of this.filterOptions) {
            group[filter.key] = new FormControl();
          }
          this.form = new FormGroup(group);
          this.formChange?.unsubscribe();
          this.formChange = this.form.valueChanges
            .pipe(
              debounceTime(500),
              distinctUntilChanged(
                (a, b) => JSON.stringify(a) === JSON.stringify(b)
              )
            )
            .subscribe({
              next: (values) => {
                this.filterChange.emit(values);
              },
            });
        }
        if (changes['filter']) {
          for (const key of Object.keys(this.filter)) {
            const ctrl = this.form.get(key);
            if (ctrl) {
              ctrl.setValue(this.filter[key]);
            }
          }
        }
      }
    }
    
    @Component({
      selector: 'shared-search-filter2',
      standalone: true,
      imports: [CommonModule, ReactiveFormsModule],
      template: `<fieldset [formGroup]="form">
      @for (option of filterOptions(); track option; let i = $index) {
          <label>{{ option.label }}</label>
          <input [type]="option.kind" [formControlName]="option.key" />
      }
    </fieldset>`,
    })
    export class SearchFilterComponent2 {
      filter = model<{ [key: string]: any }>({});
      filterOptions = input.required<readonly SearchFilterOption[]>();
    
      form = new FormGroup({});
      formChange?: Subscription;
    
      constructor() {
        effect(() => {
          if (this.formChange) {
            this.formChange.unsubscribe();
          }
          this.formChange = this.form.valueChanges
            .pipe(
              debounceTime(500),
              distinctUntilChanged(
                (a, b) => JSON.stringify(a) === JSON.stringify(b)
              ),
              tap(() => {
                untracked(() => {
                  this.filter.set(this.form.value);
                });
              })
            )
            .subscribe();
        });
      }
    
      ngOnInit() {
        const group: { [key: string]: FormControl } = {};
        for (const filter of this.filterOptions()) {
          group[filter.key] = new FormControl(this.filter()[filter.key]);
        }
        this.form = new FormGroup(group);
      }
    }
    

    Stackblitz Demo