Herman Fransen
Herman Fransen

Reputation: 2210

Angular Reactive Form manual validate gives ExpressionChangedAfterItHasBeenCheckedError

I'm using Angular 5.0.0 and Material 5.2.2.

This form has two sub questions in it. The user is submitting two times in one form. This has to stay this way because I present here a very stripped down version of my original form.

After the first submit, in the second subquestion, I validate if there is minimum one checked checkbox. If not I do an this.choicesSecond.setErrors({'incorrect': true});. This disables the submit button in a correct way. But this gives an error:

`ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'true'. Current value: 'false'.

I think it has to do with with change detection. If I do an extra change detection with this.changeDetectorRef.detectChanges() then the error disappears but the submit button is not disabled anymore.

What am I doing wrong?

Template:

<mat-card>
    <form *ngIf="myForm" [formGroup]="myForm" (ngSubmit)="onSubmit(myForm.value)" novalidate>
        <div *ngIf="!subQuestion">
            <mat-card-header>
                <mat-card-title>
                    <h3>Which fruit do you like most?</h3>
                </mat-card-title>
            </mat-card-header>
            <mat-card-content>
                <mat-radio-group formControlName="choiceFirst">
                    <div *ngFor="let fruit of fruits; let i=index" class="space">
                        <mat-radio-button [value]="fruit">{{fruit}}</mat-radio-button>
                    </div>
                </mat-radio-group>
            </mat-card-content>
        </div>
        <div *ngIf="subQuestion">
            <mat-card-header>
                <mat-card-title>
                    <h3>Whichs fruits do you like?</h3>
                </mat-card-title>
            </mat-card-header>
            <mat-card-content>
                <div *ngFor="let choiceSecond of choicesSecond.controls; let i=index">
                    <mat-checkbox [formControl]="choiceSecond">{{fruits[i]}}</mat-checkbox>
                </div>
            </mat-card-content>
        </div>
        <mat-card-actions>
            <button mat-raised-button type="submit" [disabled]="!myForm.valid">Submit</button>
        </mat-card-actions>
    </form>
</mat-card>

Component:

export class AppComponent {

  myForm: FormGroup;
  fruits: Array<string> = ["apple", "pear", "kiwi", "banana", "grape", "strawberry", "grapefruit", "melon", "mango", "plum"];
  numChecked: number = 0;
  subQuestion: boolean = false;

  constructor(private formBuilder: FormBuilder, private changeDetectorRef: ChangeDetectorRef) { }

  ngOnInit() {
    this.myForm = this.formBuilder.group({
      'choiceFirst': [null, [Validators.required]],
    });
    let choicesFormArray = this.fruits.map(fruit => { return this.formBuilder.control(false) });
    this.myForm.setControl('choicesSecond', this.formBuilder.array(choicesFormArray));
    this.onChangeAnswers();
  }

  onChangeAnswers() {
    this.choicesSecond.valueChanges.subscribe(value => {
      let numChecked = value.filter(item => item).length;
      if (numChecked === 0 ) this.choicesSecond.setErrors({'incorrect': true});
    });
  }

  get choicesSecond(): FormArray {
    return this.myForm.get('choicesSecond') as FormArray;
  };

  onSubmit(submit) {
    if (!this.subQuestion) {
      this.subQuestion = true;
      let numChecked = this.choicesSecond.controls.filter(item => item.value).length;
      if (numChecked === 0 ) this.choicesSecond.setErrors({'incorrect': true});
      // this.changeDetectorRef.detectChanges()
    }
    console.log(submit);
  }

}

Upvotes: 1

Views: 3218

Answers (2)

Herman Fransen
Herman Fransen

Reputation: 2210

The solution @ibenjelloun works well with two adjustments (see also comments under his solution):

  1. if (c.value.length >= min) needs to be if (c.value.filter(item => item).length >= min) filtering only the checkboxes that are checked.

  2. the setControl of 'choicesSecond' needs to be done after the first submit in onSubmit method and not the ngOnInit hook. Here also you need to do the setValidators and updateValueAndValidity.

As @ibenjelloun suggested the solution is better with an extra button going from first subquestion to the second subquestion because implementing a back button is only possible this way.

Here is the final component that works:

export class AppComponent {

  myForm: FormGroup;
  fruits: Array<string> = ["apple", "pear", "kiwi", "banana", "grape", "strawberry", "grapefruit", "melon", "mango", "plum"];
  subQuestion: boolean = false;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit() {
    this.myForm = this.formBuilder.group({
      'choiceFirst': [null, [Validators.required]],
    });
  }

  minLengthArray(min: number) {
    return (c: AbstractControl): { [key: string]: any } => {
      if (c.value.filter(item => item).length >= min)
        return null;
      return { 'minLengthArray': { valid: false } };
    }
  }

  get choicesSecond(): FormArray {
    return this.myForm.get('choicesSecond') as FormArray;
  };

  onSubmit(submit) {
    if (!this.subQuestion) {
      let choicesSecondFormArray = this.fruits.map(fruit => { return this.formBuilder.control(false) });
      this.myForm.setControl('choicesSecond', this.formBuilder.array(choicesSecondFormArray));
      this.myForm.get('choicesSecond').setValidators(this.minLengthArray(1));
      this.myForm.get('choicesSecond').updateValueAndValidity();
      this.subQuestion = true;
    }
    console.log(submit);
  }

} 

Upvotes: 0

ibenjelloun
ibenjelloun

Reputation: 7733

The issues comes from the this.choicesSecond.setErrors({'incorrect': true});, when you click submit you create the component and at the same time change its value. This fails in development mode because of the aditional check done by angular. Here is a good article about this error.

For form validation you can use a custom validator, an example in this post :

minLengthArray(min: number) {
    return (c: AbstractControl): {[key: string]: any} => {
        if (c.value.length >= min)
            return null;

        return { 'minLengthArray': {valid: false }};
    }
}

And for steps, as you are using angular material you could use the mat-stepper.

Upvotes: 3

Related Questions