Talk from UX people conference

My talk from uxpeople.by:
video http://vimeo.com/85995792 (Russian)

Social Share Toolbar

Validation in angular.js ng-repeat

It seems to be a kind of pretty obvious task to make validation for fields generated with angular ng-repeat directive, but actually I think it is not so obvious as I thought. Let say we have a dynamic “table” of “fields”, both fields are numbers and left one should be less then right one. BTW let say that every line of this table is a range, so the next “low” value should be as previous “high” + 1, e.g.:
[1] [2]
[3] [10]
[11] [20]

Let’s create simple form for this:

<div ng-app='formValidationApp'>
<form ng-controller="ValidationCtrl" name='rangesForm' novalidate>
    <div class='line' ng-repeat='line in ranges'>
        low: <input type='text' ng-pattern='/^\d+$/' ng-model='line.low' />
        high: <input type='text' ng-pattern='/^\d+$/' ng-model='line.up' />
        <a href ng-if='!$first' ng-click='removeRange($index)' >Delete</a>
    </div>
    <a href ng-click='addRange()'>Add Range</a>
    <input type='submit' ng-disabled='rangesForm.$invalid'>
</form>
</div>

And here is the controller:

formValidationApp = angular.module('formValidationApp', []);
formValidationApp.controller('ValidationCtrl', ['$scope', function ($scope){
    var scope_ = $scope;
    $scope.ranges = [{low: 1}, {}, {}];
    $scope.removeRange = function (index){
        scope_.ranges.splice(index, 1);
    };
    $scope.addRange = function(){
        scope_.ranges.push({});
    };
}]);

And little bit CSS:

.line {
    clear: both;
}
input.ng-invalid {
    border-color: red;
    color: red;
    background-color: pink;
}

The working code you can find in this fiddle http://jsfiddle.net/82PX4/

Ok, as you may see I’ve added basic angular validation via “ng-pattern”. Also I want to add “validation error text”, as far as you may know interpolation doesn’t work in ng-repeat, I mean you can not set input’s name to something like “low_$index”. As an example http://stackoverflow.com/questions/12044277/how-to-validate-inputs-dynamically-created-using-ng-repeat-ng-show-angular

Ok, but we have “ng-form” for this case, cool let’s use it. I added ng-form to the DIV with the repeater, and added validation error, so here is the updated html:

<div ng-app='formValidationApp'>
<form ng-controller="ValidationCtrl" name='rangesForm' novalidate>
    <div class='line' ng-repeat='line in ranges' ng-form='lineForm'>
        low: <input type='text' ng-pattern='/^\d+$/' ng-model='line.low' />
        high: <input type='text' ng-pattern='/^\d+$/' ng-model='line.up' />
        <a href ng-if='!$first' ng-click='removeRange($index)' >Delete</a>
        <div class='error' ng-show='lineForm.$error.pattern'>
            Must be a number.
        </div>
    </div>
    <a href ng-click='addRange()'>Add Range</a>
    <input type='submit' ng-disabled='rangesForm.$invalid' />
</form>
</div>

And here is the updated jsfiddle – http://jsfiddle.net/82PX4/1/

Ok that’s fine, and now let’s try to add custom validation for inputs in angular ng-repeat directive.
I won’t create angular directive, because initially the main goal of directive is “reusability”. Let say that this “range” validation is completely custom thing and this is the only usage of such kind of logic. The creation of directive will simplify the implementation of this task, but for me it’s kind of “hack” in this case, the logic is very custom and should be in controller\service\etc but not in directive.

The most efficient way (in terms of angular) to validate our “model” (I mean $scope.ranges) is to watch $scope.ranges and to validate it (the logic will operate only with data, and will watch only data), but afterward, when for example first range will be invalid, it is needed to set validity for this element and this is the problem.

It is impossible to access defined (let say the second) form (lineForm) because interpolation for form’s name doesn’t work, so it is impossible to make something like this:

rangesForm.lineForm[2].low.$setValidity('ranges', false);

or like this

rangesForm.lineForm_2.low.$setValidity('ranges', false);

That’s a pity, I hope I just didn’t find the right solution to access element by index.

The only solution that I found is to use “ng-change” for tracking changes (instead of watching the data model) and to pass “this” to the ng-change handler (so we can access the scope of current ng-form).

So, lets modify the code. Here is the ng-repeat section:

<div class='line' ng-repeat='line in ranges' ng-form='lineForm'>
        low: <input type='text' 
                    name='low'
                    ng-pattern='/^\d+$/' 
                    ng-change="lowChanged(this, $index)" ng-model='line.low' />
        up: <input type='text' 
                    name='up'
                    ng-pattern='/^\d+$/'
                    ng-change="upChanged(this, $index)" 
                    ng-model='line.up' />
        <a href ng-if='!$first' ng-click='removeRange($index)' >Delete</a>
        <div class='error' ng-show='lineForm.$error.pattern'>
            Must be a number.
        </div>
        <div class='error' ng-show='lineForm.$error.range'>
            Low must be less the Up.
        </div>
    </div>

And here is updated js:

formValidationApp = angular.module('formValidationApp', []);
formValidationApp.controller('ValidationCtrl', ['$scope', function ($scope){
    var scope_ = $scope;
    $scope.ranges = [{low: 1}, {}, {}];
    $scope.removeRange = function (index){
        scope_.ranges.splice(index, 1);
    };
    $scope.addRange = function(){
        scope_.ranges.push({});
    };
    $scope.lowChanged = function(scope, index) {
        // check ranges
        setValidity(
            scope.ranges[index].low,
            scope.ranges[index].up,
            scope.lineForm.low);
 
    };
    $scope.upChanged = function(scope, index) {
        // check ranges
        setValidity(
            scope.ranges[index].low,
            scope.ranges[index].up,
            scope.lineForm.up);
    }
 
    function setValidity(low, up, element) {
        if(low && up && !!+low && !!+up) {
            element.$setValidity('range', 
                parseInt(low) < parseInt(up));
        }
    }
}]);

Here is the working fiddle http://jsfiddle.net/82PX4/2/

Let’s little bit improve UX and add “auto filling” (I mean to set next Up value to previous Low value +1, and vice versa).
So, here is the complete solution – http://jsfiddle.net/82PX4/3/

I’m not sure that this solution is good enough, so if you know better one, please send me a mail (gmail [the doggy] Mikita Manko [dot] com)

Social Share Toolbar