Re-render ng-options after second-level change in collection
Asked Answered
L

5

16

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.

Lois answered 18/7, 2017 at 8:32 Comment(3)
You can just simply put $scope.selectedOption=newElem; inside your $scope.changeSecondLevel() functionPayee
No, I want to update the collection. Setting the selected element directly is not an option. In my application changeSecondLevel() performs a HTTP request that returns an entire set of new elements that I reassign to myCollection. I just chose a single element here for the sake of simplicity.Lois
In your application, is the id value unique in myCollection? And when you say you reassign myCollection, do you do myCollection = ... or do you iterate through the list as in your example and do myCollection[i] = ...?Cornuted
B
3

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);
  };

}]);
Blouin answered 26/7, 2017 at 1:29 Comment(4)
This seems like the most appropriate solution suggested so far. Still an ugly workaround, I'm aware of that, this mechanism would pick up all changes, no matter what level they are on. Basically, this will re-render the entire collection.Lois
Though, I wonder, does this have any impact in terms of performance? A reference to the collection is stored in a temporary variable and later restored. Sounds like a low-priced operation to me. Or does this trigger any expensive routines within AngularJS in the background?Lois
It is a low-priced operation, as you put it, since JavaScript only passes copies of a pointer around and not copies of the object. Angular is going to do the shallow comparison regardless, so as far as I see the cost of it is the same as making a change to the first level twice... nothing significant at allBlouin
I'm accepting this answer and awarding the bounty to you. The two-phase update works well and even the selected item is restored properly. Good work, thank you!Lois
P
5

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.

Pentadactyl answered 28/7, 2017 at 21:18 Comment(1)
Though this might work, it sounds like a very dirty hack and I rather do not want to do that. :)Lois
B
3

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);
  };

}]);
Blouin answered 26/7, 2017 at 1:29 Comment(4)
This seems like the most appropriate solution suggested so far. Still an ugly workaround, I'm aware of that, this mechanism would pick up all changes, no matter what level they are on. Basically, this will re-render the entire collection.Lois
Though, I wonder, does this have any impact in terms of performance? A reference to the collection is stored in a temporary variable and later restored. Sounds like a low-priced operation to me. Or does this trigger any expensive routines within AngularJS in the background?Lois
It is a low-priced operation, as you put it, since JavaScript only passes copies of a pointer around and not copies of the object. Angular is going to do the shallow comparison regardless, so as far as I see the cost of it is the same as making a change to the first level twice... nothing significant at allBlouin
I'm accepting this answer and awarding the bounty to you. The two-phase update works well and even the selected item is restored properly. Good work, thank you!Lois
S
3

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>
Selfcommand answered 26/7, 2017 at 7:3 Comment(0)
G
3

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;
}
Godbey answered 30/7, 2017 at 22:47 Comment(2)
There are multiple nested properties and I do not know beforehand, which attribute will change.Lois
Thank you for your effort, but I'd prefer a more light-weight solution that is less tailored to a specific object structure.Lois
T
1

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.

Tutt answered 31/7, 2017 at 16:30 Comment(4)
You say that telling Angular to refresh would generelly be frowned upon. Can you elaborate on that?Lois
Managing changes on my own is not an option for me, since my service method is very likely to change multiple elements of the collection which may or may not have numerous nested attributes each.Lois
Using something like $scope.$apply is usually only necessary when you are dealing with a javascript object that is outside the angular framework or a jquery object from a different library. Using something like $timeout shows that one doesn't really know how to handle promises or handling the framework correctly with $watches or $onChange, hence my opinion of it being frowned upon.Tutt
It's hard to argue without seeing your architecture, based on the jsfiddle that would be my general solution. perhaps you could refactor your service into returning a new object instead of letting it change it on its own ?Tutt

© 2022 - 2024 — McMap. All rights reserved.