Problem
I have a combo box, basically a select element that is filled with an array of complex objects by ng-options. When I update any object of the collection on second-level, this change is not applied to the combo box.
This is also documented on the AngularJS web site:
Note that $watchCollection does a shallow comparison of the properties of the object (or the items in the collection if the model is an array). This means that changing a property deeper than the first level inside the object/collection will not trigger a re-rendering.
Angular view
<div ng-app="testApp">
<div ng-controller="Ctrl">
<select ng-model="selectedOption"
ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.id">
</select>
<button ng-click="changeFirstLevel()">Change first level</button>
<button ng-click="changeSecondLevel()">Change second level</button>
<p>Collection: {{ myCollection }}</p>
<p>Selected: {{ selectedOption }}</p>
</div>
</div>
Angular controller
var testApp = angular.module('testApp', []);
testApp.controller('Ctrl', ['$scope', function ($scope) {
$scope.myCollection = [
{
id: '1',
name: 'name1',
nested: {
value: 'nested1'
}
}
];
$scope.changeFirstLevel = function() {
var newElem = {
id: '1',
name: 'newName1',
nested: {
value: 'newNested1'
}
};
$scope.myCollection[0] = newElem;
};
$scope.changeSecondLevel = function() {
var newElem = {
id: '1',
name: 'name1',
nested: {
value: 'newNested1'
}
};
$scope.myCollection[0] = newElem;
};
}]);
You can also run it live in this JSFiddle.
Question
I do understand that AngularJS does not watch complex objects within ng-options for performance reasons. But is there any workaround for this, i.e. can I manually trigger re-rendering? Some posts mention $timeout or $scope.apply as a solution, but I could utilize neither.
A quick hack I've used before is to put your select inside an ng-if, set the ng-if to false, and then set it back to true after a $timeout of 0. This will cause angular to rerender the control.
Alternatively, you might try rendering the options yourself using an ng-repeat. Not sure if that would work.
Yes, it's a bit ugly and needs an ugly work-around.
The $timeout solution works by giving AngularJS a change to recognise that the shallow properties have changed in the current digest cycle if you set that collection to [].
At the next opportunity, via the $timeout, you set it back to what it was and AngularJS recognises that the shallow properties have changed to something new and updates its ngOptions accordingly.
The other thing I added in the demo is to store the currently selected ID before updating the collection. It can then be used to re-select that option when the $timeout code restores the (updated) collection.
Demo: http://jsfiddle.net/4639yxpf/
var testApp = angular.module('testApp', []);
testApp.controller('Ctrl', ['$scope', '$timeout', function($scope, $timeout) {
$scope.myCollection = [{
id: '1',
name: 'name1',
nested: {
value: 'nested1'
}
}];
$scope.changeFirstLevel = function() {
var newElem = {
id: '1',
name: 'newName1',
nested: {
value: 'newNested1'
}
};
$scope.myCollection[0] = newElem;
};
$scope.changeSecondLevel = function() {
// Stores value for currently selected index.
var currentlySelected = -1;
// get the currently selected index - provided something is selected.
if ($scope.selectedOption) {
$scope.myCollection.some(function(obj, i) {
return obj.id === $scope.selectedOption.id ? currentlySelected = i : false;
});
}
var newElem = {
id: '1',
name: 'name1',
nested: {
value: 'newNested1'
}
};
$scope.myCollection[0] = newElem;
var temp = $scope.myCollection; // store reference to updated collection
$scope.myCollection = []; // change the collection in this digest cycle so ngOptions can detect the change
$timeout(function() {
$scope.myCollection = temp;
// re-select the old selection if it was present
if (currentlySelected !== -1) $scope.selectedOption = $scope.myCollection[currentlySelected];
}, 0);
};
}]);
Explanation of why changeFirstLevel works
You are using (selectedOption.id + ' - ' + selectedOption.name) expression to render select options labels. This means an {{selectedOption.id + ' - ' + selectedOption.name}} expression is working for select elements label. When you call changeFirstLevel func the name of selectedOption is changing from name1 to newName1. Because of that html is rerendering indirectly.
Solution 1
If the performance is not a problem for you you can simply delete the track by expression and the problem will be solved. But if you want performance and rerender at the same time both will be a bit low.
Solution 2
This directive is deep watching the changes and apply it to model.
var testApp = angular.module('testApp', []);
testApp.directive('collectionTracker', function(){
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attrs, ngModel) {
var oldCollection = [], newCollection = [], ngOptionCollection;
scope.$watch(
function(){ return ngModel.$modelValue },
function(newValue, oldValue){
if( newValue != oldValue )
{
for( var i = 0; i < ngOptionCollection.length; i++ )
{
//console.log(i,newValue,ngOptionCollection[i]);
if( angular.equals(ngOptionCollection[i] , newValue ) )
{
newCollection = scope[attrs.collectionTracker];
setCollectionModel(i);
ngModel.$setUntouched();
break;
}
}
}
}, true);
scope.$watch(attrs.collectionTracker, function( newValue, oldValue )
{
if( newValue != oldValue )
{
newCollection = newValue;
oldCollection = oldValue;
setCollectionModel();
}
}, true)
scope.$watch(attrs.collectionTracker, function( newValue, oldValue ){
if( newValue != oldValue || ngOptionCollection == undefined )
{
//console.log(newValue);
ngOptionCollection = angular.copy(newValue);
}
});
function setCollectionModel( index )
{
var oldIndex = -1;
if( index == undefined )
{
for( var i = 0; i < oldCollection.length; i++ )
{
if( angular.equals(oldCollection[i] , ngModel.$modelValue) )
{
oldIndex = i;
break;
}
}
}
else
oldIndex = index;
//console.log(oldIndex);
ngModel.$setViewValue(newCollection[oldIndex]);
}
}}
});
testApp.controller('Ctrl', ['$scope', function ($scope) {
$scope.myCollection = [
{
id: '1',
name: 'name1',
nested: {
value: 'nested1'
}
},
{
id: '2',
name: 'name2',
nested: {
value: 'nested2'
}
},
{
id: '3',
name: 'name3',
nested: {
value: 'nested3'
}
}
];
$scope.changeFirstLevel = function() {
var newElem = {
id: '1',
name: 'name1',
nested: {
value: 'newNested1'
}
};
$scope.myCollection[0] = newElem;
};
$scope.changeSecondLevel = function() {
var newElem = {
id: '1',
name: 'name1',
nested: {
value: 'newNested2'
}
};
$scope.myCollection[0] = newElem;
};
}]);
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.5/angular.min.js"></script>
<div ng-app="testApp">
<div ng-controller="Ctrl">
<p>Select item 1, then change first level. -> Change is applied.</p>
<p>Reload page.</p>
<p>Select item 1, then change second level. -> Change is not applied.</p>
<select ng-model="selectedOption"
collection-tracker="myCollection"
ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.id">
</select>
<button ng-click="changeFirstLevel()">Change first level</button>
<button ng-click="changeSecondLevel()">Change second level</button>
<p>Collection: {{ myCollection }}</p>
<p>Selected: {{ selectedOption }}</p>
</div>
</div>
Why don't you just simply track collection by that nested property ?
<select ng-model="selectedOption"
ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.nested.value">
Update
Since you don't know which property to track you can simply track all properties passing a function on track by expression.
ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by $scope.optionsTracker(selectedOption)"
And on Controller:
$scope.optionsTracker = (item) => {
if (!item) return;
const firstLevelProperties = Object.keys(item).filter(p => !(typeof item[p] === 'object'));
const secondLevelProperties = Object.keys(item).filter(p => (typeof item[p] === 'object'));
let propertiesToTrack = '';
//Similarilly you can cache any level property...
propertiesToTrack = firstLevelProperties.reduce((prev, curr) => {
return prev + item[curr];
}, '');
propertiesToTrack += secondLevelProperties.reduce((prev, curr) => {
const childrenProperties = Object.keys(item[curr]);
return prev + childrenProperties.reduce((p, c) => p + item[curr][c], '');
}, '')
return propertiesToTrack;
}
I think that any solution here will be either overkill (new directive) or a bit of a hack ($timeout).
The framework does not automatically do it for a reason, which we already know is performance. Telling angular to refresh would be generally frowned upon, imo.
So, for me, I think the least intrusive change would be to add a ng-change method and set it manually instead of relying on the ng-model change. You'll still need the ng-model there but it would be a dummy object from now on. Your collection would be assigned on the return (.then ) of the response , and let alone after that.
So, on controller:
$scope.change = function(obj) {
$scope.selectedOption = obj;
}
And each button click method assign to the object directly:
$scope.selectedOption = newElem;
instead of
$scope.myCollection[0] = newElem;
On view:
<select ng-model="obj"
ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.id"
ng-change="change(obj)">
</select>
Hope it helps.
In my controller I have a function that recieves an object from Java controller. My AngularJS variable is simple:
var self = this;
self.item = {};
And my function where I get the object:
function getItem() {
MyService.getItem(REST_SERVICE_URI)
.then(
function (d) {
self.item = d;
},
function (errResponse) {
console.error('Error while getting item');
}
);
}
Object that's received has rather complicated structure. It has id, name and list of child objects, who have also id and name fields. How do I get into this object's fields and list in the AngularJS controller? I tried loop though list using fucntion below to even count duplicate values but it didn't work. I tried even to include one more loop into it with outputing result in console, no effect. It only returns zeros.
var i = "SOME TEST NAME VALUE TO CHECK";
function getCount(i) {
var iCount = iCount || 0;
for (var el in self.item) {
console.log("let me see what are you: " + el);
if (el == i) {
iCount++;
}
}
return iCount;
}
The object I recieve is ok, I can see it content in Chrome using F12 - Network - Response or Preview.
added later:
On my page I test it like this
<tr class="my_item" ng-repeat="p in ctrl.item.children">
<span>getCount {{p.name}}: {{ctrl.getCount(p.name)}}</span>
</tr>
It displays p.name in the span btw. Java object structure is
public class Item {
private int id;
private String name;
List<Child> children = new ArrayList<>();
}
Child class is simple
public class Child {
private int id;
private String name;
}
As per your question, the content is complex and has recursive properties inside child content.
So you need to iterate on content recursively, inside one forEach loop.
See this example working Demo:
var myApp = angular.module('myApp', []);
myApp.controller('ExampleController', function() {
var vm = this;
vm.count = 0;
vm.searchTxt = "";
vm.getCount = function() {
vm.count = 0; //clear count before search
recursive(vm.content);
}
function recursive(dataArray) { //recursive function
dataArray.forEach(function(data) {
if (vm.searchTxt == data.name) { //match name property
vm.count = vm.count + 1;
}
if (data.child.length > 0) {
recursive(data.child); // call recursive function
}
});
}
vm.content = [{ //example content
id: 1,
name: 'one',
child: [{
id: 1.1,
name: 'new one',
child: [{
id: 1,
name: 'one',
child: []
}]
}]
}, {
id: 2,
name: 'two',
child: [{
id: 1.1,
name: 'new two',
child: []
}]
}]
});
<script src="https://code.angularjs.org/1.5.2/angular.js"></script>
<div ng-app="myApp" ng-controller="ExampleController as vm">
<input ng-model="vm.searchTxt" placeholder="ender search.." />
<br>
<button ng-click="vm.getCount()">Search</button>
<br>
<span>Match 'Name' count : {{vm.count}}</span>
</div>
I got the following sample response from server ( The original will be much more nested )
{
Widgets: [
{ type: 'Multivalue',value: ['val1','val2','val3'] },
{ type: 'OtherType',value: 'val', otherProp: 'prop' }
{ type: 'Text',value: 'text here' }
]
}
I know what are the available widget types so I have ViewModels for each widget as above:
var MultiValueVM = function(){
// some props
this.values = ko.observableArray()
}
var OtherValueVM = function(){
// some props
}
var TextValueVM = function(){
// some props
}
//More ViewModels for all types of widgets..
And my main ViewModel
var MainVM = function(){
//some props
this.widgets = ko.observableArray();
}
I'm in big confusion how to map the Collection of different objects from the server to my MainVM.widgets so that each widgets has its own ViewModel..
I have a native JavaScript class:
var Holder = new function(elements) {
this.elements = elements;
this.anyFunction() {
// use of this.elements
};
};
How to use it in an Angular-way? For example, if I would like to use:
.controller('AnyController', ['Holder',
function (Holder) {
var elements = [
{id: 1, label: 'foo'},
{id: 2, label: 'bar'}
];
$scope.holder = new Holder(elements);
}])
How should I register my Holder class then? What are the options (if any)?
In parallel, is it that bad to use native JavaScript classes in an Angular app (i.e. without integrating it within the framework)?
You could return a class with a factory
.factory('Holder', function() {
return (function (){
this.foo = foo;
this.bar = bar;
});
});
Now to use it
.controller('AnyController', ['Holder', function (Holder) {
var holder = new Holder();
}]);
EDIT
Use a factory instead of a service, as suggested in the comments
As I understand it, a factory is a singleton, but a factory can generate a class that can create instances. So the factory would return a reference to the constructor when you inject it, or a wrapper function around the constructor to use it without using new:
.factory('Holder', function() {
function Holder(elements) {
this.elements = elements;
}
Holder.prototype.get = function() {
return this.elements;
};
return function(elements) {
return new Holder(elements);
};
})
.controller('Main', function($scope, Holder) {
var elements = [
{id: 1, label: 'foo'},
{id: 2, label: 'bar'}
];
$scope.elements = Holder(elements).get();
});
Here is a very basic attempt to create a "hello world"-like JS app using the module and MVC patterns.
var appModules = {};
appModules.exampleModul = (function () {
var _data = ['foo', 'bar']; // private variable
return {
view: {
display: function() {
$('body').append(appModules.exampleModul.model.getAsString());
},
},
model: {
getAsString: function() {
return _data.join(', ');
},
}
};
})();
appModules.exampleModul.view.display();
This works fine, but I'm not happy how I have to reference the model function from the view, using the full object path: appModules.exampleModul.model.getAsString(). How can I expose the public model methods to the view, so I could simply use something like model.getAsString()? Or do I need to organize the code differently?
One option is you can convert those objects into private implementations.
appModules.exampleModul = (function() {
var _data = ['foo', 'bar'];
// private variable
var _view = {
display : function() {
$('body').append(_model.getAsString());
},
};
var _model = {
getAsString : function() {
return _data.join(', ');
},
};
return {
view : _view,
model : _model
};
})();
You could do something like this:
var appModules = {};
appModules.exampleModul = (function () {
var _data = ['foo', 'bar']; // private variable
return {
view: {
display: function() {
$('body').append(this.model.getAsString());
},
},
model: {
getAsString: function() {
return _data.join(', ');
},
}
};
})();
var display = appModules.exampleModul.view.display.bind(appModules.exampleModul);
display();
Which isn't really the prettiest of solutions, but does offer a more generic solution inside the display function!