I'm trying to create a form which is built dynamically according to a certain model using Angular's reactive forms. The model is a complex object which can change, and contains nested objects and values, for example (in TypeScript notation):
interface Model {
// ... other stuff
config: { // complex object which can change, here's an example for some structure
database: {
uri: string;
name: string;
};
server: {
endpoint: {
host: string;
port: number;
};
requestTimeoutMs: number;
};
};
}
Using this object, I built a FormGroup
like so:
@Component({
// ...
templateUrl: './form.component.html'
})
export class FormComponent {
@Input() model: Model;
public form: FormGroup;
// instantiating the whole form
public ngOnInit(): void {
this.form = new FormGroup({
// ... instantiating other controls using the model,
config: new FormGroup({}),
});
// building the config form using model.config which is dynamic
this.buildForm(this.form.get('config') as FormGroup, this.model.config);
}
// helper functions used in the template
public isGroup(control: AbstractControl): control is FormGroup {
return control instanceof FormGroup;
}
public typeOf(value: any): string {
return typeof value;
}
// building the config form
private buildForm(group: FormGroup, model: object): void {
for (const key in obj) {
const prop = obj[key];
let control: AbstractControl;
if (prop instanceof Object) { // nested group
control = new FormGroup({});
this.buildForm(control as FormGroup, prop);
} else { // value
control = new FormControl(prop);
}
group.addControl(control);
}
}
}
This code works as expected and creates the form according to the object. I know this doesn't check for arrays and FormArray
s, because I don't use them currently.
After building the form, I'm trying to display the whole form in my template, which is where I've got a problem. This is my template:
<form [ngForm]="form" (ngSubmit)="...">
<!-- ... other parts of the form -->
<ng-container [ngTemplateOutlet]="formItemTemplate"
[ngTemplateOutletContext]="{parent: form, name: 'config': model: model; level: 0}">
</ng-container>
<ng-template #formItemTemplate let-parent="parent" let-name="name" let-model="model" let-level="level">
<ng-container [ngTemplateOutlet]="isGroup(parent.get(name)) ? groupTemplate : controlTemplate"
[ngTemplateOutletContext]="{parent: parent, name: name, model: model, level: level}">
</ng-container>
</ng-template>
<ng-template #groupTemplate let-parent="parent" let-name="name" let-model="model" let-level="level">
<ul [formGroup]="parent.get(name)">
<li>{{name | configName}}</li>
<ng-container *ngFor="let controlName of parent.get(name) | keys">
<ng-container [ngTemplateOutlet]="formItemTemplate"
[ngTemplateOutletContext]="{name: controlName, model: model[name], level: level + 1}">
</ng-container>
</ng-container>
</ul>
</ng-template>
<ng-template #controlTemplate let-name="name" let-model="model" let-level="level">
<li class="row">
<div class="strong">{{name | configName}}</div>
<ng-container [ngSwitch]="typeOf(obj[name])">
<input type="text" *ngSwitchCase="'string'" [formControlName]="name">
<input type="number" *ngSwitchCase="'number'" [formControlName]="name">
<input type="checkbox" *ngSwitchCase="'boolean'" [formControlName]="name">
</ng-container>
</li>
</ng-template>
</form>
Let me break it down for you, in case the component's template isn't understood:
I have formItemTemplate
whose job is to distinguish if parent.get(name)
(the child control) is a FormGroup
or a FormControl
. If the child control is a FormGroup
, a child template of groupTemplate
is created, otherwise controlTemplate
.
In groupTemplate
I created a ul
node which binds [formGroup]
to parent.get(name)
(the child group) and in it (hierarchically) then proceed to create a formItemTemplate
template for each control of that child group, whose control names I got using a keys
pipe which I've created, which just returns Object.keys()
for the given value. Bascially - recursion.
In controlTemplate
I just create a new li
and in it I create an input which binds [formControlName]
to the name
given in [ngTemplateOutletContext]
.
The problem is that I get the following error:
Cannot find control with the name: '...'
I know it happens if you bound [formControlName]
on a control with a name that isn't found in the scope of the [formGroup]
bound to your FormGroup. That is weird, because I even checked in DevTools and can see that ng-reflect-form="[object Object]"
attribute is applied to the ul
of the group, which means it indeed is bound and most probably to the correct FormGroup
.
When debugging isGroup()
with additional arguments passed from the template's context (such as model
and name
) I've noticed that isGroup()
is called many times for each object, sequentially, and not once for each object, like so:
name
?And then it crashes without completing the iteration over the whole model, unless I click on some input in my component, which is so weird.
I've seen this question which is similar to mine but it wasn't helpful for problem. Thanks in advance!
Solved my own question by adding [formGroup]="parent"
to the li
element in controlTemplate
. Whoops!