Вложенные формы 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()
, исключив асинхронное получение данных внутри формы.