Reputation: 3302
I don't like either of Angular's form validation routes, but I'm investigating a way to combine the best of each, and am close to an answer I like.
I know that regardless whether I go the formBuilder
or the ngModel
route, there is an NgForm
for the form, which has a property holding the root FormGroup
which has a heterogeneous collection of FormControl
objects. HTML Elements all have an adapter object implementing the ControlValueAccessor
interface, and my own angular components like <date-range-picker>
can implement the same interface and pretend to be just-another-element whose value is an arbitrarily complex object. Each FormControl
wraps an element, talking with it via the ControlValueAccessor
interface so it is agnostic as to what, exactly, it is actually talking to.
I know that placing either the ngModel
or formControl
directive on an element will create the FormControl
instance for that element; the element doesn't get one automatically, even though a <form>
tag gets an NgForm
automatically.
I know that formBuilder
will explicitly create hollow FormControl
s which lack the HTML element, but each has a name, and in the HTML the formControlName
gives the HTML elements a name but no FormControl
instance, and basically formControlName
and formBuilder
both talk to a service that matches the names and fills-in the hollow FormControl
with its element.
Finally, FormControl
is where the validators live, as well as the dirty/touched/etc. properties.
My issue with ngModel
is the same as everyone's: validation sucks. A custom validation is little more than the condition of an if
statement, but ngModel
wants me to wrap that little condition in an entire directive and stick it in the HTML on the element. That's a lot of extra typing for an if
statement -- you can't make a one-liner re-usable because it takes one line to use the wrapper. And cross-field validation sucks.
My issue with formBuilder
is the assignment statements. For a model of 12 properties I'm writing 24 lines, 12 to put the values into the form and 12 to get them back out again, in a type-unsafe manner. That's a lot of extra typing that ngModel didn't require, and it kinda violates the DRY principle since I have to repeat the list and hierarchy of input fields in the HTML within the Typescript as well.
Lately I do this:
<input type=text name=foo [(ngModel)]="myModel.myProperty" />
with
@ViewChild(NgModel) mod: NgModel;
ngAfterViewInit() {
this.mod.control.setValidators([Validators.required, Validators.minLength(3)]);
}
and
<span class=danger *ngIf="mod?.control?.errors?.required">....
This gives me the best of both worlds, concision and control.
But for <date-range-picker>
I find I still have to use the ControlValueAccessor
boilerplate, which means I then cannot use ngModel
to shuttle values between its small 3-property object being returned and my official 12-property model source-of-truth. Three explicit assignment statements are needed. I want to avoid those, and avoid more angular-specific boilerplate.
It would be easy if the FormControl
s inside the picker's HTML could see the ngForm in the HTML using the picker, but it can't.
My question is: how does a FormControl
register itself with an NgForm
? FormBuilder
doesn't take an NgForm
as an input parameter, it 'just knows' which form to attach. Even if there's multiple forms in the same HTML template, it gets it right. If there's a service hiding behind it that goes and finds the NgForm, can I use that service from my picker to find an NgForm that is outside of its own template?
Upvotes: 3
Views: 3028
Reputation: 3302
FormControl
s receive the NgForm
instance from the ngModel
/ formControlName
constructor, which is placed there by Angular DI. This "boilerplate" in a component's decorator:
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateRangePickerComponent),
multi: true
}
...registers a custom component (which is implementing ControlValueAccessor
) with the DI system. Specifically, NG_VALUE_ACCESSOR
plays the same role as the PickerService
does here:
export class MyComponent {
constructor(pickerService: PickerService)
The multi: true
part means the injected thing isn't just one service, like the PickerService is, but is actually a group of them. RadioControlValueAccessor
, SelectControlValueAccessor
, and CheckboxControlValueAccessor
sit under this umbrella, and your own DateRangePicker
could be among them if you use the "boilerplate." Angular selects the correct one for the job at hand when looking through an HTML template.
Wrapping a component in a lambda for forwardRef
just solves a small order-of-initialization issue, nothing more.
Basically, implementing ControlValueAccessor
makes a class what Angular expects, and the decorator specifies where in Angular to put it.
But if you really don't want to use it...
Use a template reference var on the form in the parent's HTML, and pass it to child component like it was any other value:
<form #theForm="ngForm" ...
<date-range-picker [form]="theForm" ...
In the child component, accept the form like any other input, and also grab a reference to the ngModel used in the child's HTML (which you've already done for validator purposes):
@Input() form: NgForm;
@ViewChild(NgModel) mod: NgModel;
Add one to the other imperatively.
ngAfterViewInit() {
this.mod.control.setValidators([Validators.required, c => c.value.duration != 0]);
this.form.addControl(this.mod);
}
And you're basically done. There may be issues with *ngIf
destroying and re-creating said control, or change detection not being as thorough as it normally would be, but solving these issues in this way means you're effectively re-inventing Angular.
This starts to become apparent when you have multiple ngModels in a child's template:
@ViewChildren(NgModel) ngModels: QueryList<NgModel>;
readonly validations = {
'reasonField': [Validators.required, Validators.maxLength(500)],
'durationField': [Validators.required, c => c.value.duration != 0],
};
ngAfterViewInit() {
this.ngModels.forEach(ngModel => {
ngModel.control.setValidators(this.validations[ngModel.name]);
this.form.addControl(ngModel);
});
}
..and now tying the validations to the fields starts to look a lot like formBuilder
all over again. (Without the 24 assignment statements.)
Upvotes: 2