The use case
Side Note: this post was inspired by this StackOverflow question
Let's talk about the below behavior:
If you look carefully at the above animated gif you'll notice 2 important things about the displayed form behavior:
- It's dynamically created
- It can be recursively nested indefinitely
A static image will help us to better understand the scenario:
I've highlighted some repeating parts on the form above. The red parts are very interesting, so take your time looking at them. Have you notice something with them? It's recursively nested. You can see that Group 2 has a Nested Group 1 inside it and both of them are structurally identical. In fact, Group 2 and the Nested Group 1 have the same elements: conditions and the same action buttons bar.
How would we build up a dynamically, recursively nested form like the one shown in the image above? What if we want to create and fill-up the form based on some data you load from a server? Keep reading and we'll get there in a minute.
The complete code discussed here is available at Stackblitz.com:
The pseudo component tree
We'll build the following structure (in this "pseudo component tree" each label started with app
represents an angular component):
app-group-control
|
app-action-buttons-bar
|
conditions:
app-condition-form
app-condition-form
app-condition-form
...
|
groups:
app-group-control
app-group-control
app-group-control
...
In the above pseudo component tree, app-group-control
is an angular reactive form that contains an action bar and 2 FormArray
controls: conditions
and groups
.
Now I want you to be a curious developer and notice that groups
is a collection of app-group-control
components. Imagine this: you're gonna a build a component: app-group-control
. In its template, among other things, it has a collection of... itself. It's a composite component.
Let's dive a little in each part of this component.
Action bar
Inside app-group-control
the first thing we'll find is this action bar (ActionButtonsBarComponent
in Stackblitz demo) that allows us to:
- Add a
app-group-control
togroups
FormArray
(let's call it a "nested group") - Add a condition to the
conditions
FormArray
- When the user clicks on the delete group button, it will ask the component that hosts this instance of
app-group-control
to be removed
This is a very simple component with 3 @Ouput()
-decorated properties:
@Output()
remove = new EventEmitter<void>();
@Output()
addGroup = new EventEmitter<void>();
@Output()
addCondition = new EventEmitter<void>();
Condition
The condition component (ConditionFormComponent
in Stackblitz demo) is also a very simple component. But, it has an important thing you should notice: it implements the ControlValueAccessor
interface.
The ControlValueAccessor
interface is what makes it possible for you to turn your components into angular FormControl
s. And like any FormControl
, they can be part of forms (for example, they can receive the formControlName
directive and be controlled by the available @angular/forms
utilities and methods).
The ControlValueAccessor
is also, what make it possible for us to nest forms in the most possible simple way. I've seen a lot of people doing crazy things to nest forms, like passing the parent FormGroup
to a child component in order to reuse it in the child component's form. Maybe it's a matter of style, but I'd strongly recommend anyone to just turn that child component (that contains the nested form) into a FormControl
. By doing that you'll be flattening the form's structure. I'd suggest two Kara Erickson videos if you want to learn more cool stuff about angular forms. Finish reading this post and, after that, scroll your page back to check out these amazing videos.
The wrapper form component
Finally we have this wrapper component, that contains the form that wraps everything (GroupControlComponent
in Stackblitz demo).
Basically, its template is:
<form [formGroup]="_form">
<app-action-buttons-bar (remove)="remove.emit()"
(addGroup)="_addGroup()"
(addCondition)="_addCondition()">
</app-action-buttons-bar>
<ng-container formArrayName="conditions">
<app-condition-form *ngFor="let c of _conditionsFormArray?.controls; index as j"
(remove)="_deleteCondition(j)"
[formControlName]="j"
[formLabel]="'Condition ' + (j+1)">
</app-condition-form>
</ng-container>
<ng-container formArrayName="groups">
<app-group-control *ngFor="let s of _groupsFormArray?.controls; index as i"
(remove)="_deleteGroupFromArray(i)"
[formControlName]="i"
[formLabel]="'Nested Group '+ (i + 1) + ':'">
</app-group-control>
</ng-container>
</form>
If you check the code in Stackblitz, you'll notice that <app-condition-form>
has its own form inside. <app-group-control>
also has its own form (we're actually looking at it in the code above). Thanks to ControlValueAccessor
, nesting these two forms inside a parent form was a piece of cake. Take a look again... we're not only nesting two forms in a completely transparent way, which would be a huge achievement per se. Considering that conditions
and groups
are FormArrays
, we're, in fact, nesting an indefinite number of forms inside the parent form, without the need of any hack, using a simple, clean and robust approach. Pretty cool, don't you think?
The recursion
Like if it wasn't awesome enough to nest an indefinite number of forms in the first nested level, we're nesting host component instances inside itself... for an indefinite number of nesting levels down. It deserves a huge OMG!!! I hope this can give you a measure of the power of angular component-based structure.
Create the form based on incoming data
As a part of the implementation of the ControlValueAccessor
interface, we implemented the writeValue(...)
. It is called by angular whenever the FormControl.setValue()
or FormControl.patchValue()
is executed by the host form. And this is a perfect place to analyze the incoming data and create/remove/fill-up controls of our form according to the incoming data.
If you take a look at this modified stackblitz demo, you'll see how we can achieve this with two tiny snippets (both of them in the writeValue(...)
functions of the GroupControlComponent
):
writeValue(value: Record<string,any>) {
...
// Here we create the `conditions` controls based on
// existent `conditions` attribute in the value
if (value && value.conditions && value.conditions.length) {
this._conditionsFormArray.clear();
value.conditions.forEach(c => this._addCondition());
}
...
// Here we create the `groups` controls based on
// existent `groups` attribute in the value
if (value && value.groups && value.groups.length) {
this._groupsFormArray.clear();
value.groups.forEach(g => this._addGroup());
}
...
}
So, in the AppComponent
(in the demo), when we call this._form.patchValue(data);
in the buildFormFromData()
method, our form is built automatically to accommodate data
in the right fields. With ControlValueAccessor
in place, this was pretty easy, wasn't it?
Final Considerations
If you try to build this same form without angular ControlValueAccessor
, I can guarantee you painful headaches during all the process. For some reason, the ControlValueAcessor
is one of the most underestimated angular features among developers. Not using it makes the overall process of building complex forms an overwhelming task.
Give my schematics library a try
BTW, I've built an schematics libray that generates the skeleton of a component with the ControlValueAccessor
implementation for you. If you want to, fell free to try it in your projects (it's a devDependency
, so it won't add any nano dependency to your project): https://www.npmjs.com/package/@julianobrasil/schematics-components
Credits: Cover image from https://undraw.co
Top comments (3)
HI juliano
That was a Great solution can you please add form group with the validations with the same example like condition is required That will be great Full i am stuck up with it
Please help i am stuck UP
HI juliano
That was a Great solution can you please add form group with the validations with the same example like condition is required That will be great Full i am stuck up with it
Please help i am stuck UP
This solution was extremely helpful and addressed my issue perfectly. Thank you for providing such a clear and effective approach!