Вложенные формы Angular

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

Постановка задачи

Имеется форма из 2 полей

<ng-container [formGroup]="formGroup">
  <!-- поля start -->
  <div class="field">
    <label>City</label>
    <input type="text" formControlName="city" />
  </div>
  <div class="field">
    <label>Street</label>
    <input type="text" formControlName="street" />
  </div>
  <!-- поля end -->
</ng-container>
readonly formGroup = new FormGroup({
    city: new FormControl('', [Validators.required]),
    street: new FormControl('', [Validators.required, Validators.minLength(2)]),
});

Требуется её использовать в готовом виде в других формах.

Основным источником информации является видео Angular Forms: Advanced Topics – AngularConnect 2017 от Kara Erickson

Pass FormGroup to @Input() (Form Projection)

Общая рекомендация: не передавать всю форму, лучше прокидывать отдельный контрол.

Варианты использования

  • Error aggregator
  • Form wizard или stepper
@Component({
  selector: 'app-address-form-first',
  template: `
    <ng-container [formGroup]="parentForm">
        <ng-container formGroupName="first-address">
            <!-- поля -->
        </ng-container>
    </ng-container>`,
})
export class AddressFormFirstComponent implements OnInit {
  @Input() parentForm!: FormGroup;
  readonly formGroup = new FormGroup({...});

  ngOnInit(): void {
    this.parentForm.addControl('first-address', this.formGroup);
  }
}

использование

<form [formGroup]="formGroup" >
    <app-address-form-first [parentForm]="formGroup"></app-address-form-first>
</form>    

Форма сама вставляет себя в родительскую FormGroup.

Providing ControlContainer

@Component({
  selector: 'app-address-form-second',
  template: `
    <ng-container formGroupName="second-address">
        <!-- поля -->
    </ng-container>`,
  viewProviders: [ { provide: ControlContainer, useExisting: FormGroupDirective } ],
})
export class AddressFormSecondComponent implements OnInit {
  readonly formGroup = new FormGroup({...});

  constructor(private parentForm: FormGroupDirective) {}

  ngOnInit(): void {
    this.parentForm.form.addControl('second-address', this.formGroup);
  }
}

использование

<form [formGroup]="formGroup" >
    <app-address-form-second></app-address-form-second>
</form>    

Форма сама вставляет себя в родительскую FormGroup.

ControlValueAccessor

Рекомендуемый подход от создателей Angular. В этом случае всё форма выступает одним контролом со значением-объектом. Данный подход требует написания шаблонного кода для реализации ControlValueAccessor. Однако шаблонный код можно поместить в отдельный класс, тогда вся реализация подхода сводится к объявлению двух провайдеров NG_VALUE_ACCESSOR и NG_VALIDATORS.

@Component({
  selector: 'app-address-form-third',
  template: `
    <ng-container [formGroup]="formGroup">
        <!-- поля -->
    </ng-container>`,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: AddressFormThirdComponent,
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: AddressFormThirdComponent,
      multi: true,
    },
  ],
})
export class AddressFormThirdComponent extends FormControlValueAccessorAdapter {
  readonly formGroup = new FormGroup({...});
}

Используется untilDestroyed для отписки, но можно отписываться любым другим способом.

/**
 * Presents form as FormControl, value is an object
 */
@UntilDestroy()
export abstract class FormControlValueAccessorAdapter
  implements ControlValueAccessor, Validator {

  abstract formGroup: FormGroup;

  onTouched: () => void = () => {};

  writeValue(val: any): void {
    if (val) {
      this.formGroup.setValue(val, { emitEvent: false });
    }
  }

  registerOnChange(fn: any): void {
    this.formGroup.valueChanges.pipe(untilDestroyed(this)).subscribe(fn);
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState?(isDisabled: boolean): void {
    isDisabled ? this.formGroup.disable() : this.formGroup.enable();
  }

  validate(c: AbstractControl): ValidationErrors | null {
    return this.formGroup.valid
      ? null
      : {
          invalidForm: {
            value: this.formGroup.value,
            message: `Nested form is invalid`,
          },
        };
  }
}

Важный момент в том, что CVA проводит чёткую границу между родительской и дочерней формой. Поэтому родительская форма:

  • не имеет доступа до отдельных контролов дочерней формы
  • может получать ошибки только со всей формы
  • может включать/отключать только целую форму
  • метод patchValue/setValue контрола формы должен принимать объект со всеми значениями формы

использование

<form [formGroup]="formGroup" >
    <app-address-form-third formControlName="third-address"></app-address-form-third>
</form>

FormControl создаётся в родительской форме.

  readonly formGroup = new FormGroup({
    'third-address': new FormControl(),
  });

Catch @Output() from child

@Component({
  selector: 'app-address-form-fourth',
  template: `
    <ng-container [formGroup]="formGroup">
        <!-- поля -->
    </ng-container>`,
})
export class AddressFormFourthComponent implements OnInit {
  readonly formGroup = new FormGroup({...});
  @Output() formReady = new EventEmitter<FormGroup>();

  ngOnInit(): void {
    // когда действительно пройдет инициализация
    this.formReady.emit(this.formGroup);
  }
}

использование

<form [formGroup]="formGroup" >
    <app-address-form-fourth (formReady)="addChildFormGroup($event)"></app-address-form-fourth>
</form>

Контрол добавляется в методе родительской формы.

readonly formGroup = new FormGroup();

addChildFormGroup(formGroup: FormGroup): void {
    this.formGroup.addControl('fourth-address', formGroup);
}

Child form has view only

Дочерняя форма отвечает только за визуальную составляющую (template) формы. Динамическое имя для дочерней группы.

@Component({
  selector: 'app-address-form-fifth',
  template: `
    <ng-container *ngIf="formGroup" [formGroup]="formGroup">
        <!-- поля -->
    </ng-container>`,
})
export class AddressFormFifthComponent implements OnInit {
  formGroup?: FormGroup;

  constructor(
    private parentForm: FormGroupDirective,
    @Optional() private formGroupName: FormGroupName
  ) {}

  ngOnInit(): void {
    this.formGroup = this.formGroupName?.name
      ? (this.parentForm.form.controls[this.formGroupName.name] as FormGroup)
      : this.parentForm.form;
  }
}

использование

<form [formGroup]="formGroup" >
    <app-address-form-fifth formGroupName="fifth-address"></app-address-form-fifth>
</form>

Контролы для всех полей дочерней формы создаются в родительской форме.

readonly formGroup = new FormGroup({
    'fifth-address': new FormGroup({
        city: new FormControl('', [Validators.required]),
        street: new FormControl('fifth-address', [
            Validators.required,
            Validators.minLength(2),
        ]),
    }),
});

ViewChild()

Дочерняя форма предельно простая, все манипуляции в родительской форме.

@Component({
  selector: 'app-address-form-sixth',
  template: `
    <ng-container [formGroup]="formGroup">
        <!-- поля -->
    </ng-container>`,
})
export class AddressFormSixthComponent implements OnInit {
  readonly formGroup = new FormGroup({...});
}

использование

<form [formGroup]="formGroup" >
    <app-address-form-sixth></app-address-form-sixth>
</form>
@ViewChild(AddressFormSixthComponent, { static: true })
readonly viewChildFormComponent!: AddressFormSixthComponent;

readonly formGroup = new FormGroup();

ngOnInit(): void {
    this.formGroup.addControl('sixth-address', this.viewChildFormComponent.formGroup);
}

Вместо заключения

Большинство подходов имеют вариацию получения ControlContainer:

  • указывать явно <ng-container [formGroup]="formGroup"> ... </ng-container>
  • получать ControlContainer через provide: ControlContainer

Наиболее независимый подход при использовании ControlValueAccessor, он же требует хороший знаний и понимания Angular Forms API. Варианты, когда дочерние формы вставляет себя в родительскую форму (FormGroup via @Input() и Providing ControlContainer) не очень по определению.

Рекомендуется все данные в дочерние формы передавать через @Input(), исключив асинхронное получение данных внутри формы.


Похожие записи

Angular. Отличие baseHref от deployUrl

  • deployUrl - задаёт путь для статических (js, css) файлов в index.html.
  • baseHref - определяет base, используется в ссылках и маршрутизации (routing) Angular