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):
model.d.ts
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:
form.component.ts
@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.component.html
<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:
- config
- database
- uri
- config - again? why? why wasn't it called for
name
? - database
- uri
- name
- config - 3rd time???
- database
- uri
- name
- server - should've been called in the 5th iteration
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!