EDIT: here is the Editor url for my Stackblitz app that shows in a simplified setting the problem of "transfer" of value between first control and second one.
I'm working on a dynamic form in Angular where the form structure changes based on user input. Each question in the form is represented by a unique control within a FormGroup, and the controls are dynamically created or updated as the user navigates through the questions. My approach involves updating the currentControlName and recreating the form controls on navigation (using nextPage and prevPage methods) to ensure that each question has its own unique control within the form.
Approach:
I use a service to fetch questions based on the selected service. The form is initialized in ngOnInit with the first question as the current question. When navigating between questions, I create a new FormControl for each question, identified by a unique name using the question ID. I store responses in an object to maintain the answers even when navigating back and forth between questions.
Problem:
After entering a response for the first question and navigating to the second, the input field for the second question incorrectly shows the value entered for the first question. Additionally, updating the value of the second question doesn't trigger the expected valueChanges observable, and it seems like there's a mismatch or a binding issue between the form controls and the template.
Specifically:
The first input control works as expected, logging value changes. After navigating to the second question and updating its control, no changes are logged, indicating that the observable might not be correctly set up for the newly created control. Despite recreating the form group and controls on each navigation, there seems to be a persistent issue with the binding of control values in the template.
Attempts to Resolve:
Ensured unique names for each FormControl based on the question ID. Used valueChanges observable to log and track changes in control values. Recreated the FormGroup and its controls upon each navigation to try and reset the form state.
I'm looking for insights or solutions to ensure that each dynamically created control correctly reflects its value in the template and that value changes are accurately detected and logged. Any suggestions on how to address the binding issue or improve the dynamic form handling in Angular would be greatly appreciated.
Angular CLI: 14.2.4 Node: 21.6.1
EDIT: minimalist code to reproduce my problem
minimal.component.ts
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
interface Question {
id: string;
label: string;
}
@Component({
selector: 'app-minimal-form',
templateUrl: './minimal.component.html',
})
export class MinimalFormComponent implements OnInit {
myForm: FormGroup;
currentQuestion: Question;
questions: Question[] = [
{ id: 'A', label: 'Question A' },
{ id: 'B', label: 'Question B' },
];
currentControlName: string;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.currentQuestion = this.questions[0];
this.currentControlName = `question_${this.currentQuestion.id}`;
this.myForm = this.fb.group({
[this.currentControlName]: ['', Validators.required],
});
}
goToNextQuestion(): void {
const currentIndex = this.questions.findIndex(q => q.id === this.currentQuestion.id);
const nextIndex = currentIndex + 1;
if (nextIndex < this.questions.length) {
this.currentQuestion = this.questions[nextIndex];
this.currentControlName = `question_${this.currentQuestion.id}`;
this.myForm = this.fb.group({
[this.currentControlName]: ['', Validators.required],
});
}
}
}
minimal.component.html
<form [formGroup]="myForm">
<label>{{ currentQuestion.label }}</label>
<input type="text" [formControlName]="currentControlName">
<button type="button" (click)="goToNextQuestion()">Next Question</button>
</form>
If you have to use your original approach, I suggest you just have a control called question
and update/reset the value wherever needed, we can use a different property output
to track the values when they get saved!
import { Component } from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
interface Question {
id: string;
label: string;
}
@Component({
selector: 'app-minimal-form',
template: `
<form [formGroup]="myForm">
<label>{{ questions[currentQuestionIndex]?.label }}</label>
<input type="text" formControlName="question">
<button type="button" (click)="goToNextQuestion()">Next Question</button>
</form>
{{output | json}}
`,
})
export class AppComponent {
myForm: FormGroup;
currentQuestion: Question;
questions: Question[] = [
{ id: 'A', label: 'Question A' },
{ id: 'B', label: 'Question B' },
];
currentControlName: string;
currentQuestionIndex = 0;
output: any = {};
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.myForm = this.fb.group({
question: this.fb.control('', Validators.required),
});
}
goToNextQuestion(): void {
if (this.currentQuestionIndex < this.questions.length) {
const currentQuestionCtrl = this.myForm.get('question');
this.output[`question_${this.questions[this.currentQuestionIndex].id}`] =
currentQuestionCtrl.value;
currentQuestionCtrl.reset();
if (this.currentQuestionIndex + 1 !== this.questions.length) {
this.currentQuestionIndex++;
}
}
}
}
There needs to an input associated with a form control on the HTML, we cannot swap the inputs with different form control names and expect it to work! Here is an alternative approach where you setup the inputs on the beginning and then using the HTML attribute hidden
we hide/show the current value, which is tracked using the index in currentQuestionIndex
, this seems to work great!
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
interface Question {
id: string;
label: string;
}
@Component({
selector: 'app-minimal-form',
template: `
<form [formGroup]="myForm">
<ng-container *ngFor="let question of questions; trackBy: track; let i = index">
<span [hidden]="i !== currentQuestionIndex">
<label>{{ questions[i].label }}</label>
<input type="text" [formControlName]="'question_' + question.id">
</span>
</ng-container>
<button type="button" (click)="goToNextQuestion()">Next Question</button>
</form>
{{myForm.value | json}}
`,
})
export class AppComponent {
myForm: FormGroup;
currentQuestion: Question;
questions: Question[] = [
{ id: 'A', label: 'Question A' },
{ id: 'B', label: 'Question B' },
];
currentQuestionIndex: number = 0;
constructor(private fb: FormBuilder) {}
track(index: number, item: Question) {
return item.id;
}
ngOnInit(): void {
this.currentQuestion = this.questions[0];
this.myForm = this.fb.group({});
this.currentQuestionIndex = 0;
this.questions.forEach((question: Question) => {
this.myForm.addControl(
`question_${question.id}`,
this.fb.control('', Validators.required)
);
});
}
goToNextQuestion(): void {
this.currentQuestionIndex++;
}
}