In our angularjs projects we are often dealing with existing models that do not always fit to angularjs expectations.
Here is an example.
There is a model consisting of two arrays: for data, and for associated data. How to create an ng-repeat
that displays data from both sources?
Consider a test controller (see a github sources, and a rawgit working sample):
model.controller(
"Test",
function()
{
this.records =
[
{ name: "record 1", state: "Draft" },
{ name: "record 2", state: "Public" },
{ name: "record 3", state: "Disabled" },
{ name: "record 4", state: "Public" },
{ name: "record 5", state: "Public" }
];
this.more =
[
{ value: 1, selected: true, visible: true },
{ value: 2, selected: false, visible: true },
{ value: 3, selected: true, visible: true },
{ value: 4, selected: false, visible: false },
{ value: 5, selected: false, visible: true }
];
this.delete = function(index)
{
this.records.splice(index, 1);
this.more.splice(index, 1);
};
});
Basically there are three approaches here:
- Change model.
- Adapt model to a single collection.
ng-repeat
over first array and access the second array using $index
scope variable.
We argued like this:
- It is often not an option to change a model, as it's how business data are described.
-
A model adaptation when we build a single collection from original two collections, and synchronize it back (if required) may unnecessary complicate things.
-
Thus let's get associated items by
$index
variable.
This is an example of ng-repeat
use:
<table border="1">
<tr>
<th>[x]</th>
<th>Name</th>
<th>Value</th>
<th>State</th>
<th>Actions</th>
</tr>
<tr ng-repeat="item in test.records track by $index"
ng-if="test.more[$index].visible">
<td>
<input type="checkbox" ng-model="test.more[$index].selected"/>
</td>
<td>{{item.name}}</td>
<td>{{test.more[$index].value}}</td>
<td>{{item.state}}</td>
<td>
<a href="#" ng-click="test.delete($index)">Delete</a>
</td>
</tr>
</table>
Look at how associated data is accessed: test.more[$index]...
Our goal was to optimize that repeating parts, so we looked at ng-init
directive.
Though docs warn about its use: "the only appropriate use of ngInit is for aliasing special properties of ngRepeat", we thought that our use of ng-init is rather close to what docs state, so we tried the following:
...
<tr ng-repeat="item in test.records track by $index"
ng-init="more = test.more[$index]"
ng-if="more.visible">
<td>
<input type="checkbox" ng-model="more.selected"/>
</td>
<td>{{item.name}}</td>
<td>{{more.value}}</td>
<td>{{item.state}}</td>
<td>
<a href="#" ng-click="test.delete($index)">Delete</a>
</td>
</tr>
...
This code just does not work, as it shows empty table, as if ng-if
is always evaluated to false
. From docs we found the reason:
- the priority of the directive
ng-if
is higher than the prirority of the ng-init
, and besides ng-if
is a terminal
directive;
-
as result
ng-if
directive is bound, and ng-init
is not;
- when
ng-if
is evaluated no $scope.more
is defined, so more.visible
is evaluated to false
.
To workaround ng-init
/ng-if
problem we refactored ng-if
as ng-if-start
/ng-if-end
:
...
<tr ng-repeat="item in test.records track by $index"
ng-init="more = test.more[$index]">
<td ng-if-start="more.visible">
<input type="checkbox" ng-model="more.selected"/>
</td>
<td>{{item.name}}</td>
<td>{{more.value}}</td>
<td>{{item.state}}</td>
<td ng-if-end>
<a href="#" ng-click="test.delete($index)">Delete</a>
</td>
</tr>
...
This code works much better and shows a correct content. But then click "Delete" for a row with Name "record 2" and you will find that updated table is out of sync for all data that come from test.more
array.
So, why the data goes out of sync? The reason is in the way how the ng-init
is implemented: its expression is evaluated just once at directive's pre-link phase. So, the value of $scope.more
will persist for the whole ng-init
's life cycle, and it does not matter that test.mode[$index]
may have changed at some point.
At this point we have decided to introduce a small directive named ui-eval
that will act in a way similar to ng-init
but that:
- will run before
ng-if
;
- will be re-evaluated when it's value is changed.
This is it:
module.directive(
"uiEval",
function()
{
var directive =
{
restrict: 'A',
priority: 700,
link:
{
pre: function(scope, element, attr)
{
scope.$watch(attr["uiEval"]);
}
}
};
return directive;
});
The ui-eval
version of the markup is:
...
<tr ng-repeat="item in test.records track by $index"
ui-eval="more = test.more[$index]"
ng-if="more.visible">
<td>
<input type="checkbox" ng-model="more.selected"/>
</td>
<td>{{item.name}}</td>
<td>{{more.value}}</td>
<td>{{item.state}}</td>
<td>
<a href="#" ng-click="test.delete($index)">Delete</a>
</td>
</tr>
...
It works as expected both during initial rendering and when model is updated.
We consider ui-eval
is a "better" ng-init
as it solves ng-init's silent limitations. On the other hand it should not try to evaluate any complex logic, as it can be often re-evaluated, so its use case is to alias a sub-expression. It can be used in any context and is not limited to items of ng-repeat
.
Source code can be found at github, and a working sample at rawgit.