Skip to content
This repository has been archived by the owner on Aug 29, 2023. It is now read-only.

Commit

Permalink
fix(select): when using trackBy, trigger ng-change only when tracked …
Browse files Browse the repository at this point in the history
…property is different

* Add mdSelect demo to ease debugging
* Add support for ng-model-options to be updated after initialization
* Add and fix tests

Closes #11108
  • Loading branch information
chenlijun99 authored and Splaktar committed Mar 6, 2020
1 parent 90c8b8d commit ecd55d0
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 21 deletions.
52 changes: 52 additions & 0 deletions src/components/select/demoTrackBy/index.html
@@ -0,0 +1,52 @@
<div layout="column" layout-align="center center" class="md-padding" ng-cloak>
<div layout="row" layout-align="space-between">
<div ng-controller="AppCtrl as ctrl" layout="column" flex="40">
<div>
<h1 class="md-title">Without trackBy</h1>
<div layout="row">
<md-input-container>
<label>Items</label>
<md-select ng-model="ctrl.selectedItem"
ng-change="ctrl.modelHasChanged = true">
<md-option ng-repeat="item in ctrl.items" ng-value="item">
{{ item.name }}
</md-option>
</md-select>
</md-input-container>
</div>
</div>
<div layout="column">
<h5>Initial model</h5>
<code><pre>{{ ::ctrl.selectedItem | json }}</pre></code>
<h5>Current model</h5>
<code><pre>{{ ctrl.selectedItem | json }}</pre></code>
<span ng-show="ctrl.modelHasChanged">Model has changed</span>
</div>
</div>

<div ng-controller="AppCtrl as ctrl" layout="column" flex="40">
<div>
<h1 class="md-title">With trackBy</h1>
<div layout="row">
<md-input-container>
<label>Items</label>
<md-select ng-model="ctrl.selectedItem"
ng-change="ctrl.modelHasChanged = true"
ng-model-options="{ trackBy: '$value.id' }">
<md-option ng-repeat="item in ctrl.items" ng-value="item">
{{ item.name }}
</md-option>
</md-select>
</md-input-container>
</div>
</div>
<div layout="column">
<h5>Initial model</h5>
<code><pre>{{ ::ctrl.selectedItem | json }}</pre></code>
<h5>Current model</h5>
<code><pre>{{ ctrl.selectedItem | json }}</pre></code>
<span ng-show="ctrl.modelHasChanged">Model has changed</span>
</div>
</div>
</div>
</div>
27 changes: 27 additions & 0 deletions src/components/select/demoTrackBy/script.js
@@ -0,0 +1,27 @@
(function() {
'use strict';
angular
.module('selectDemoTrackBy', ['ngMaterial', 'ngMessages'])
.controller('AppCtrl', function() {
this.selectedItem = {
id: '5a61e00',
name: 'Bob',
randomAddedProperty: 123
};

this.items = [
{
id: '5a61e00',
name: 'Bob',
},
{
id: '5a61e01',
name: 'Max',
},
{
id: '5a61e02',
name: 'Alice',
},
];
});
})();
4 changes: 4 additions & 0 deletions src/components/select/demoTrackBy/style.css
@@ -0,0 +1,4 @@
code {
display: block;
padding: 8px;
}
79 changes: 59 additions & 20 deletions src/components/select/select.js
Expand Up @@ -235,12 +235,13 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $

// Use everything that's left inside element.contents() as the contents of the menu
var multipleContent = isMultiple ? 'multiple' : '';
var ngModelOptions = attr.ngModelOptions ? $mdUtil.supplant('ng-model-options="{0}"', [attr.ngModelOptions]) : '';
var selectTemplate = '' +
'<div class="md-select-menu-container" aria-hidden="true" role="presentation">' +
'<md-select-menu role="presentation" {0}>{1}</md-select-menu>' +
'<md-select-menu role="presentation" {0} {1}>{2}</md-select-menu>' +
'</div>';

selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, element.html()]);
selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, ngModelOptions, element.html()]);
element.empty().append(valueEl);
element.append(selectTemplate);

Expand Down Expand Up @@ -742,28 +743,40 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) {
return !self.options[self.hashGetter($viewValue)];
};

// Allow users to provide `ng-model="foo" ng-model-options="{trackBy: 'foo.id'}"` so
// Allow users to provide `ng-model="foo" ng-model-options="{trackBy: '$value.id'}"` so
// that we can properly compare objects set on the model to the available options
var trackByOption = $mdUtil.getModelOption(ngModel, 'trackBy');

if (trackByOption) {
var trackByLocals = {};
var trackByParsed = $parse(trackByOption);
self.hashGetter = function(value, valueScope) {
trackByLocals.$value = value;
return trackByParsed(valueScope || $scope, trackByLocals);
};
// If the user doesn't provide a trackBy, we automatically generate an id for every
// value passed in
} else {
self.hashGetter = function getHashValue(value) {
if (angular.isObject(value)) {
return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
//
// If the user doesn't provide a trackBy, we automatically generate an id for every
// value passed in with the getId function
if ($attrs.ngModelOptions) {
self.hashGetter = function(value) {
var ngModelOptions = $parse($attrs.ngModelOptions)($scope);
var trackByOption = ngModelOptions && ngModelOptions.trackBy;

if (trackByOption) {
return $parse(trackByOption)($scope, { $value: value });
} else if (angular.isObject(value)) {
return getId(value);
}
return value;
};
} else {
self.hashGetter = getId;
}
self.setMultiple(self.isMultiple);

/**
* If the value is an object, get the unique, incremental id of the value.
* If it's not an object, the value will be converted to a string and then returned.
* @param value
* @returns {string}
*/
function getId(value) {
if (angular.isObject(value) && !angular.isArray(value)) {
return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
}
return value + '';
}
};

self.selectedLabels = function(opts) {
Expand Down Expand Up @@ -867,15 +880,41 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) {
values.push(self.selected[hashKey]);
}
}
var usingTrackBy = $mdUtil.getModelOption(self.ngModel, 'trackBy');

var newVal = self.isMultiple ? values : values[0];
var prevVal = self.ngModel.$modelValue;

if (usingTrackBy ? !angular.equals(prevVal, newVal) : (prevVal + '') !== newVal) {
if (!equals(prevVal, newVal)) {
self.ngModel.$setViewValue(newVal);
self.ngModel.$render();
}

function equals(prevVal, newVal) {
if (self.isMultiple) {
if (!angular.isArray(prevVal)) {
// newVal is always an array when self.isMultiple is true
// thus, if prevVal is not an array they are different
return false;
} else if (prevVal.length !== newVal.length) {
// they are different if they have different length
return false;
} else {
// if they have the same length, then they are different
// if an item in the newVal array can't be found in the prevVal
var prevValHashes = prevVal.map(function(prevValItem) {
return self.hashGetter(prevValItem);
});
return newVal.every(function(newValItem) {
var newValItemHash = self.hashGetter(newValItem);
return prevValHashes.some(function(prevValHash) {
return prevValHash === newValItemHash;
});
});
}
} else {
return self.hashGetter(prevVal) === self.hashGetter(newVal);
}
}
};

function renderMultiple() {
Expand Down
38 changes: 37 additions & 1 deletion src/components/select/select.spec.js
Expand Up @@ -172,7 +172,11 @@ describe('<md-select>', function() {
it('should not trigger ng-change without a change when using trackBy', function() {
var changed = false;
$rootScope.onChange = function() { changed = true; };
$rootScope.val = { id: 1, name: 'Bob' };

// Since we're tracking by id, ng-change shouldn't be triggered
// when we have two objects that are not strictly equivalent (one has a 'randomAddedProperty')
// but that have the same tracked field
$rootScope.val = { id: 1, name: 'Bob', randomAddedProperty: 'random' };

var opts = [{ id: 1, name: 'Bob' }, { id: 2, name: 'Alice' }];
var select = setupSelect('ng-model="$root.val" ng-change="onChange()" ng-model-options="{trackBy: \'$value.id\'}"', opts);
Expand All @@ -185,6 +189,38 @@ describe('<md-select>', function() {
expect(changed).toBe(true);
});

it('should support trackBy to be updated', function() {
var changed = false;
$rootScope.onChange = function() { changed = true; };
$rootScope.useTrackBy = false;
$rootScope.trackByOption = '$value.id';

var opts = [ { id: 1, name: 'Bob' }, { id: 2, name: 'Alice' } ];
$rootScope.val = opts[0];
var select = setupSelect('ng-model="$root.val"' +
'ng-change="onChange()"' +
'ng-model-options="{ trackBy: $root.useTrackBy ? $root.trackByOption : undefined }"', opts);
expect(changed).toBe(false);

$rootScope.$apply(function() {
$rootScope.useTrackBy = true;
// Since we're tracking by id, ng-change shouldn't be triggered
// when we have two objects that are not strictly equivalent (one has a 'randomAddedProperty')
// but that have the same tracked field
$rootScope.val = { id: 1, name: 'Bob', randomAddedProperty: 'random' };
});
openSelect(select);
clickOption(select, 0);
$material.flushInterimElement();
expect(changed).toBe(false);

openSelect(select);
clickOption(select, 1);
$material.flushInterimElement();
expect($rootScope.val.id).toBe(2);
expect(changed).toBe(true);
});

it('should set touched only after closing', function() {
var form = $compile('<form name="myForm">' +
'<md-select name="select" ng-model="val">' +
Expand Down

0 comments on commit ecd55d0

Please sign in to comment.