AngularJS form validation vs. ng-repeat

Angular’s ng-repeat directive inside a form causes named inputs validation conflict. It is a problem I have seen many times, but never had a time to develop the proper solution. And Now is The Time


Problem

Let’s create new form with list of similar items to edit:

<form name="myForm">
    <li ng-repeat="mate in cooperators">
        <label for="mate-{{ $index }}">
            {{ mate.name }}
        </label>
        <input
            id="mate-{{ $index }}"
            ng-model="mate.position" />
    </li>
    <button type="submit">
        Update
    </button>
</form>

Looks ok. Labels will work, as $index is added to scope of ng-repeat before linking phase (during transclusion). It could be id="mate-{{ mate.name }}" if name is unique or some id id="mate-{{ mate.id }}" (same applies to for="…" of a label).

But let’s try to add dynamic names to inputs, so we can validate form as a whole:

<form name="myForm">
    <li ng-repeat="mate in cooperators">
        <label for="mate-{{ $index }}">
            {{ mate.name }}
        </label>
        <input
            name="mate-{{ $index }}"
            id="mate-{{ $index }}"
            ng-model="mate.position" />
    </li>
    <button type="submit">
        Update
    </button>
</form>

And now we discover that myForm controller has only one input controller appended under mate-{{ $index }} key, regardless of the number of cooperators the model holds.

Let  cooperators.length = n + 1.

Form controller now looks like this:

myForm: {
    "mate-{{ $index }}" : {}
}

The problem is that we expected something different:

myForm: {
    "mate-0" : {},
    "mate-1" : {},
    "mate-2" : {},
    ...
    "mate-{n}" : {}
}

A little research

Lets see how AngularJS ngModel directive controller reads name of an input:

...
this.$name = $attr.name;
...

AngularJS source: /v1.2.x/src/ng/directive/input.js#L1066

Controller is instantiated before linking phase. Html element attributes are interpolated between pre and post link functions invocations. So during controller instantiation we get the original attribute content as a string. In our situation it’s a not interpolated expression: 'mate-{{ $index }}'.

 

Then ngModel directive in postLink function tries to add own controller to closest ancestor form controller:

...
formCtrl.$addControl(modelCtrl);
...

AngularJS source: /v1.2.x/src/ng/directive/input.js#L1374

 

So the form controller will try to add the model controller instance to itself under modelCtrl.$name which is 'mate-{{ $index }}':

...
if (control.$name) {
    form[control.$name] = control;
}
...

AngularJS source: /v1.2.x/src/ng/directive/form.js#L93


The Solution

We have two points in time:

  • ngModel controller sets it’s own name
  • ngModel postLink function registers own controller to ancestor form controller

We need another point in the middle, that meets two conditions:

  • html is already compiled
  • ngModel did not register itself yet

Let’s stick to post linking phase because html is compiled before it. ngModel‘s priority is 0. To forestall ngModel‘s registration in formCtrl we need postLink function of directive with priority lower than 0:

  • ngModel controller sets it’s own name
  • another low priority directive’s postLink function fixes ngModel‘s name
  • ngModel postLink function registers own controller to ancestor form controller

And there’s only one condition – to be sure that model is already in $scope, we have to do this inside ng-repeat. Getting back to our form:

<form name="myForm">
    <li ng-repeat="mate in cooperators">
        <label for="mate-{{ $index }}">
            {{ mate.name }}
        </label>
        <input
            tri:fix-input-name
            name="mate-{{ $index }}"
            id="mate-{{ $index }}"
            ng-model="mate.position" />
    </li>
    <button type="submit">
        Update
    </button>
</form>

And simple tri:fix-input-name directive:

app.directive('triFixInputName', function () {
    return {
        // just postLink
        link: function (scope, element, attrs, ngModelCtrl) {
            // do nothing in case of no 'name' attribiute
            if (!attrs.name) { 
                return;
            }
            // fix what should be fixed
            ngModelCtrl.$name = attrs.name;
        },
        // ngModel's priority is 0
        priority: '-100',
        // we need it to fix it's behavior
        require: 'ngModel'
     };
});

And the only restriction here is that used input names have to be unique – just like id. Above example have $index only because list elements won’t be removed. Make sure to use unique identifier if you plan to manipulate list elements.


Next:

Control over the uniqueness of input names will be discussed here in close future. Stay up to date – sign up to our newsletter!

Demo:

Feel free to fiddle around with my solution below and experience the pros and cons: