2
votes

I have the following button in my template:

<button type="button" class="mgmButton" (click)="onSave()" [disabled]="saveDisabled()">Save</button>

The button is disabled based on the result of the saveDisabled function.

saveDisabled(): boolean {
    this.validationMessage = '';
    for (var i = 0; i < this.tableData.length; i++) {
        let row = this.tableData[i];
        if (row.edit) {
            if (row.data.roleCode == null || row.data.roleCode == '' ||
                row.data.grantProgramCode == null || row.data.grantProgramCode == '') {
                this.validationMessage = 'Row ' + (i + 1) + ' has not filled in all required fields. ';
            }
        }
    }

    if(this.validationMessage == '') {
        return false;
    } else {
        return true;
    }

An earlier version of the function didn't build the validationMessage, it just returned true or false. That worked w/o any errors. But when I added the validationMessage property to the method/component/template, I started getting the “expression has changed after it was checked” exception.

Based on other posts it looks like this occurs because I'm changing the validationMessage variable while change detection is still happening. I'm not sure I fully understand what's going on or the best way to get rid of the error.

Update:

I created a custom validator and it almost works perfectly.

My component has an array of data called tableData. Each row in tableData is an object that gets displayed as a row in an html table in the template. Sometimes a row is in a read-only mode, other times the data is in edit mode and so certain columns in the row are input fields, select drop-downs, etc.

The custom validator is applied to the form tag. It takes tableData as the input. All my validation logic works, and if the validator returns an error I display it in the template. (I did have to turn the tableData into a json string and then parse it to get the hand-off between component and validator to work correctly.)

But there seems to be an issue with the timing. Let's say that a given row is in edit mode and the user changes the value of a select menu. This select is bound to a property in one of the rows of tableData. The validation in the form is triggered, but the data passed in has the old value of the select, not the new one. In essence, the validation of the form happens before the data binding on a table row updates the backing object.

4

4 Answers

0
votes

@Vilmantas correctly described what is happening. What he didn't say though is that this approach is incorrect in general. It should be the other way around - disabled flag should be calculated based on validity flag that is already calculated before change detection even starts, not at the same time.

Angular 2 provides special validation mechanics exactly for this task: interface Validator which you can implement in your custom directive, and this same directive can provide itself as a multi-provider of NG_VALIDATORS token. Below is the example from one of my projects.

Also, validators can be asynchronous (provider for NG_ASYNC_VALIDATORS token), this is built-in option too. In this case you can consider "pending" flag to paint some indicator of pending validation, like checking user name availability by calling some web service.

After you provide this validator to ngModel you can just use standard ngModel.errors map with values, or ngModel.valid flag (and other flags too - there are bunch of them) to figure out if there were any errors and enable/disable controls based on it. Using this approach you don't need to even consider when it gets executed - angular will take care of everything for you.

Of course, you have to organize this thing so that it would be available in the template context, I mean correct imports/exports in some module(s)/component(s) and such stuff.

import {Directive, forwardRef} from '@angular/core';
import {Validator, AbstractControl, NG_VALIDATORS} from '@angular/forms';
import {IDateValidationResult} from './date-validator.interfaces';
import {moment} from '../../../shared/moment';

const DATE_VALIDATOR = {
    provide: NG_VALIDATORS,
    useExisting: forwardRef(() => DateValidatorDirective),
    multi: true
};

@Directive({
    selector:
        '[whatever-selector-you-want]',
    providers: [DATE_VALIDATOR]
})
export class DateValidatorDirective implements Validator {

    validate = (control: AbstractControl): IDateValidationResult => {
        if (!control) {
            return {required: true};
        }
        if (control.value === undefined) {
            return {required: true};
        }
        if (control.value === null) {
            return {invalidFormat: true};
        }
        let m = moment(control.value);
        if (!m.isValid()) {
            return {invalidFormat: true};
        }
        return null;
    }

}

Template:

<input type="date" [(ngModel)]="myModel" whatever-selector-you-want #myDateCtrl="ngModel">
<div *ngIf="myDateCtrl.invalid">
    <div *ngIf="myDateCtrl.errors.required">The date is required</div>
    <div *ngIf="myDateCtrl.errors.invalidFormat">Date format is invalid</div>
</div>
9
votes

I solved by adding ChangeDetectionStrategy from angular core.

import {  Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'page1',
  templateUrl: 'page1.html',
})
1
votes

Vilmantas explained it pretty well. You're changing a binding (validationMessage) without triggering a new round of change detection, hence the error. It serves as a warning that the current value of validationMessage might not be reflected in the UI until something comes along in the future that does trigger change detection.

To fix, just trigger change detection manually after updating the validation message:

import { ChangeDetectorRef } from '@angular/core';

export class Whatever {
    constructor(private cdr: ChangeDetectorRef) {}

    myMethod() {
        // do stuff here
        this.cdr.detectChanges(); // detect changes
    }
}

Angular does have great built in support for forms, though, I do agree that would be preferable. This code you have now appears to iterate over the entire tableData array for every table row, and it will run many times during change detection. At the very least, you might want to pass i to it as a parameter (it's available in the ngFor loop) and save yourself the for loop.

0
votes

I'm not too familiar with the angular2 internals, however I imagine it is something like this:

  • validationMessage appears before the Save button on the template.
  • When template is rendered for the first time, validationMessage has no value.
  • Algorithm proceeds and now saveDisabled() is called which in turn changes validatonMessage value.
  • => now angular complains that the value of already-rendered/checked validationMessage has changed within one rendering/check cycle.

To solve the problem, it would be best to move the validation code out of saveDisabled() method and store the boolean result into a field or you could even use the same validationMessage to disable the button:

<button type="button" ... [disabled]="validationMessage">Save</button>

(empty or null string will evaluate to false, non-empty message - to true)

Try to put the code where tableData is modified.

However, if your tableData is some kind of a form, the standard angular2 form with validators (standard or custom) would most likely be even better.