Использование сигналов с динамическими угловыми формами?

У меня есть общий компонент, который создает форму на основе структуры SearchFilterOption.

Вот (упрощенно):

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>

и я хотел попробовать использовать сигналы для ввода и вывода этого компонента, но, похоже, все это разваливается из-за ошибок отсутствия полей формы, когда я это делаю:

    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>

Я также попробовал вариант, в котором подписка использует toSignal, и тот, в котором computed используется для создания form, и я не могу понять, что еще делать.

Есть ли работающий метод сделать это с сигналами прямо сейчас?

Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Angular и React для вашего проекта веб-разработки?
Angular и React для вашего проекта веб-разработки?
Когда дело доходит до веб-разработки, выбор правильного front-end фреймворка имеет решающее значение. Angular и React - два самых популярных...
Эпизод 23/17: Twitter Space о будущем Angular, Tiny Conf
Эпизод 23/17: Twitter Space о будущем Angular, Tiny Conf
Мы провели Twitter Space, обсудив несколько проблем, связанных с последними дополнениями в Angular. Также прошла Angular Tiny Conf с 25 докладами.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
Мое недавнее углубление в Angular
Мое недавнее углубление в Angular
Недавно я провел некоторое время, изучая фреймворк Angular, и я хотел поделиться своим опытом со всеми вами. Как человек, который любит глубоко...
Освоение Observables и Subjects в Rxjs:
Освоение Observables и Subjects в Rxjs:
Давайте начнем с основ и постепенно перейдем к более продвинутым концепциям в RxJS в Angular
2
0
79
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Примечание: на данный момент это лучший подход, который я могу придумать, но когда придут сигналы реактивной формы, этот код будет избыточным.


Ваш код работает нормально, все, что вам нужно сделать, это переместить цикл for, создающий элементы управления формы, в 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

allowSignalWrites будет удален, вместо него используйте untracked
Bojan Kogoj 23.07.2024 11:26

К сожалению, это, похоже, ответ: прямо сейчас вы не можете реактивно изменять элементы управления формой с помощью сигналов, поэтому вы можете только инициализировать значения (начиная с Angular 18.0).

Manstie 24.07.2024 04:36
Ответ принят как подходящий

Не зная характера ошибок, я могу лишь предполагать решение и рекомендовать некоторые изменения, чтобы упростить компонент.

  1. Я считаю, что основная проблема в версии сигналов, скорее всего, связана с циклическим использованием filterOptions при построении формы с эффектом. Время запуска эффектов не обязательно будет совпадать с моментом изменения входа. Поэтому вам нужно сначала проверить, существует ли элемент управления формой, или использовать FormArray и пройти через него в шаблоне.
  2. По вашему мнению, вы проходили через filterOptions напрямую, не вызывая сигнал как функцию. Если это не изменить в версии сигнала, оно сломается. Я вообще-то об этом только что предупреждал в статье — Angular Component Bindings: Transitioning from Decorators to Functions.
  3. Не пересоздавайте форму при каждом изменении параметров фильтра, просто сбросьте ее и заново создайте элементы управления.
  4. В форме сброс эффекта фильтра доступа с неотслеживаемым. В противном случае каждый раз, когда она меняется, форму придется переделывать.
  5. Используйте отдельный эффект для обновления значений формы. Да, это может привести к некоторой избыточности, но это будет работать так же, как и раньше.
  6. Вместо использования модели разделите входные и выходные данные фильтра, чтобы можно было использовать выходFromObservable для отправки изменений. Таким образом, вам не нужно поддерживать какие-либо подписки.
readonly filter = input<{ [key: string]: any }>({});
readonly filterOptions = input.required<readonly SearchFilterOption[]>();
readonly form = new FormGroup({});
readonly filterChange = outputFromObservable(this.form.valueChanges
  .pipe(
    debounceTime(500),
    distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
  ));
constructor() {
  effect(() => {
    this.form.reset();
    Object.keys(this.form.controls).forEach(key => this.form.removeControl(key));
    const filter = untracked(this.filter); // changes here won't cause an effect.
    for (const opt of this.filterOptions()) {
      this.form.addControl(opt.key, new FormControl(filter[opt.key]));
    }
  });
  effect(() => {
    const filter = this.filter();
    for (const [key, value] of Object.entries(this.filter)) {
      this.form.get(key)?.setValue(value);
    }
  });
}
<fieldset [formGroup] = "form">
  <!-- Don't forget to call fitlerOptions since it is a function now. -->
  @for (option of filterOptions(); track option; let i = $index) {
    @if (form.get(option.key); as formCtrl) {
      <label>{{ option.label }}</label>
      <input [type] = "option.kind" [formControl] = "formCtrl" />
    }
  }
</fieldset>

Это несколько хороших концепций, но именно этот первый эффект по-прежнему вызывает ошибки в привязках форм: ERROR Error: There is no FormControl instance attached to form control element with name: 'test'. (Обратите внимание, что ваш ответ не подходит .addControl, но я изменил его на это)

Manstie 24.07.2024 04:33

@Manstie - Помимо недостающего addControl, я обнаружил проблему: параметры входного фильтра менялись до запуска эффекта. Я обновил ответ, чтобы использовать FormGroup, но FormArray, вероятно, будет лучше, поскольку вы можете легко перебирать элементы в шаблоне. Возможно, вам нужна FormGroup для чего-то еще, поэтому я не буду ее менять. В следующий раз добавьте в пост больше информации, чтобы мы точно знали, что и где ошибка. :-)

Daniel Gimenez 24.07.2024 16:08

Спасибо, вы, вероятно, можете удалить № 2 из своего ответа, поскольку для краткости я пропустил весь блок кода представления вместо того, чтобы вставить его снова с добавлением (), но я понимаю, как это может вызвать затруднения при ответе.

Manstie 28.07.2024 13:52

Другие вопросы по теме