Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
fix(select): throw for selectAs and trackBy
Browse files Browse the repository at this point in the history
trackBy and selectAs have never worked together, and are fundamentally
incompatible since model changes cannot deterministically be
reflected back to the view. This change throws an error to help
developers better understand this scenario.
  • Loading branch information
jeffbcross committed Oct 8, 2014
1 parent aad6095 commit abdaab7
Show file tree
Hide file tree
Showing 3 changed files with 55 additions and 170 deletions.
30 changes: 30 additions & 0 deletions docs/content/error/ngOptions/trkslct.ngdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@ngdoc error
@name ngOptions:trkslct
@fullName Comprehension expression cannot contain both `select as` and `track by` expressions.
@description

This error occurs when 'ngOptions' is passed a comprehension expression that contains both a
`select as` expression and a `track by` expression. These two expressions are fundamentally
incompatible.

* Example of bad expression: `<select ng-options="item.subItem as item.label for item in values track by item.id" ng-model="selected">`
`values: [{id: 1, label: 'aLabel', subItem: {name: 'aSubItem'}}, {id: 2, label: 'bLabel', subItem: {name: 'bSubItem'}}]`,
`$scope.selected = {name: 'aSubItem'};`
* track by is always applied to `value`, with purpose to preserve the selection,
(to `item` in this case)
* To calculate whether an item is selected, `ngOptions` does the following:
1. apply `track by` to the values in the array:
In the example: [1,2]
2. apply `track by` to the already selected value in `ngModel`:
In the example: this is not possible, as `track by` refers to `item.id`, but the selected
value from `ngModel` is `{name: aSubItem}`.

Here's an example of how to make this example work by using `track by` without `select as`:

```
<select ng-model="selected" ng-options="item.label for item in values track by item.id">
```

Note: This would store the whole `item` as the model to `scope.selected` instead of `item.subItem`.

For more information on valid expression syntax, see 'ngOptions' in {@link ng.directive:select select} directive docs.
24 changes: 15 additions & 9 deletions src/ng/directive/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ var ngOptionsMinErr = minErr('ngOptions');
* </div>
*
* <div class="alert alert-info">
* **Note:** Using `selectAs` will bind the result of the `selectAs` expression to the model, but
* the value of the `select` and `option` elements will be either the index (for array data sources)
* **Note:** Using `select as` will bind the result of the `select as` expression to the model, but
* the value of the `<select>` and `<option>` html elements will be either the index (for array data sources)
* or property name (for object data sources) of the value within the collection.
* </div>
*
Expand Down Expand Up @@ -79,19 +79,18 @@ var ngOptionsMinErr = minErr('ngOptions');
* even when the options are recreated (e.g. reloaded from the server).
* <div class="alert alert-info">
* **Note:** Using `selectAs` together with `trackexpr` is not possible (and will throw).
* TODO: Add some nice reasoning here, add a minErr and a nice error page.
* reasoning:
* **Note:** Using `select as` together with `trackexpr` is not possible (and will throw).
* Reasoning:
* - Example: <select ng-options="item.subItem as item.label for item in values track by item.id" ng-model="selected">
* values: [{id: 1, label: 'aLabel', subItem: {name: 'aSubItem'}}, {id: 2, label: 'bLabel', subItem: {name: 'bSubItemß'}}],
* $scope.selected = {name: 'aSubItem'};
* - trackBy is always applied to `value`, with purpose to preserve the selection,
* - track by is always applied to `value`, with purpose to preserve the selection,
* (to `item` in this case)
* - to calculate whether an item is selected we do the following:
* 1. apply `trackBy` to the values in the array, e.g.
* 1. apply `track by` to the values in the array, e.g.
* In the example: [1,2]
* 2. apply `trackBy` to the already selected value in `ngModel`:
* In the example: this is not possible, as `trackBy` refers to `item.id`, but the selected
* 2. apply `track by` to the already selected value in `ngModel`:
* In the example: this is not possible, as `track by` refers to `item.id`, but the selected
* value from `ngModel` is `{name: aSubItem}`.
*
* </div>
Expand Down Expand Up @@ -367,6 +366,13 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
//re-usable object to represent option's locals
locals = {};

if (trackFn && selectAsFn) {
throw ngOptionsMinErr('trkslct',
"Comprehension expression cannot contain both selectAs '{0}' " +
"and trackBy '{1}' expressions.",
selectAs, track);
}

if (nullOption) {
// compile the element since there might be bindings in it
$compile(nullOption)(scope);
Expand Down
171 changes: 10 additions & 161 deletions test/ng/directive/selectSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ describe('select', function() {
});


describe('trackBy', function() {
describe('trackBy expression', function() {
beforeEach(function() {
scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}];
scope.obj = {'10': {score: 10, label: 'ten'}, '20': {score: 20, label: 'twenty'}};
Expand Down Expand Up @@ -761,173 +761,22 @@ describe('select', function() {
});


describe('selectAs+trackBy', function() {
describe('selectAs+trackBy expression', function() {
beforeEach(function() {
scope.arr = [{id: 10, label: 'ten'}, {id:'20', label: 'twenty'}];
scope.obj = {'10': {score: 10, label: 'ten'}, '20': {score: 20, label: 'twenty'}};
});


it('should bind selectAs expression result to scope (array&single)', function() {
createSelect({
'ng-model': 'selected',
'ng-options': 'item.id as item.name for item in values track by item.id'
});

scope.$apply(function() {
scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}];
scope.selected = 10;
});
expect(element.val()).toEqual('0');

scope.$apply(function() {
scope.selected = 20;
});
expect(element.val()).toEqual('1');

element.val('0');
browserTrigger(element, 'change');
expect(scope.selected).toBe(10);
});


it('should bind selectAs expression result to scope (array&multiple)',function() {
createSelect({
'ng-model': 'selected',
'multiple': true,
'ng-options': 'item.id as item.name for item in values track by item.id'
});

scope.$apply(function() {
scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}];
scope.selected = [10];
});
expect(element.val()).toEqual(['0']);

scope.$apply(function() {
scope.selected = [20];
});
expect(element.val()).toEqual(['1']);

element.children(0).attr('selected', 'selected');
element.children(1).attr('selected', 'selected');
browserTrigger(element, 'change');
expect(scope.selected).toEqual([10, 20]);
});


it('should bind selectAs expression result to scope (object&single)', function() {
createSelect({
'ng-model': 'selected',
'ng-options': 'value.score as value.label for (key, value) in obj track by value.score'
});

scope.$apply(function() {
scope.selected = 10;
});
expect(element.val()).toEqual('10');

scope.$apply(function() {
scope.selected = 20;
});
expect(element.val()).toEqual('20');

element.val('10');
browserTrigger(element, 'change');
expect(scope.selected).toBe(10);
});


it('should bind selectAs expression result to scope (object&multiple)', function() {
createSelect({
'ng-model': 'selected',
'multiple': true,
'ng-options': 'value.score as value.label for (key, value) in obj track by value.score'
});

scope.$apply(function() {
scope.selected = [10];
});
expect(element.val()).toEqual(['10']);

scope.$apply(function() {
scope.selected = [20];
});
expect(element.val()).toEqual(['20']);

element.find('option')[0].selected = 'selected';
browserTrigger(element, 'change');
expect(scope.selected).toEqual([10, 20]);
});


it('should correctly assign model if track & select expressions differ (array&single)', function() {
createSelect({
'ng-model': 'selected',
'ng-options': 'item.label as item.label for item in arr track by item.id'
});

scope.$apply(function() {
scope.selected = 'ten';
});
expect(element.val()).toBe('0');

element.val('1');
browserTrigger(element, 'change');
expect(scope.selected).toBe('twenty');
});


it('should correctly assign model if track & select expressions differ (array&multiple)', function() {
createSelect({
'ng-model': 'selected',
'multiple': true,
'ng-options': 'item.label as item.label for item in arr track by item.id'
});

scope.$apply(function() {
scope.selected = ['ten'];
});
expect(element.val()).toEqual(['0']);

element.find('option')[1].selected = 'selected';
browserTrigger(element, 'change');
expect(scope.selected).toEqual(['ten', 'twenty']);
});

it('should throw a helpful minerr', function() {
expect(function() {

it('should correctly assign model if track & select expressions differ (object&single)', function() {
createSelect({
'ng-model': 'selected',
'ng-options': 'val.label as val.label for (key, val) in obj track by val.score'
});

scope.$apply(function() {
scope.selected = 'ten';
});
expect(element.val()).toBe('10');

element.val('20');
browserTrigger(element, 'change');
expect(scope.selected).toBe('twenty');
});


it('should correctly assign model if track & select expressions differ (object&multiple)', function() {
createSelect({
'ng-model': 'selected',
'multiple': true,
'ng-options': 'val.label as val.label for (key, val) in obj track by val.score'
});

scope.$apply(function() {
scope.selected = ['ten'];
});
expect(element.val()).toEqual(['10']);
createSelect({
'ng-model': 'selected',
'ng-options': 'item.id as item.name for item in values track by item.id'
});

element.find('option')[1].selected = 'selected';
browserTrigger(element, 'change');
expect(scope.selected).toEqual(['ten', 'twenty']);
}).toThrowMinErr('ngOptions', 'trkslct', "Comprehension expression cannot contain both selectAs 'item.id' and trackBy 'item.id' expressions.");
});
});

Expand Down Expand Up @@ -1264,7 +1113,7 @@ describe('select', function() {
it('should bind to scope value and track/identify objects', function() {
createSelect({
'ng-model': 'selected',
'ng-options': 'item as item.name for item in values track by item.id'
'ng-options': 'item.name for item in values track by item.id'
});

scope.$apply(function() {
Expand Down

0 comments on commit abdaab7

Please sign in to comment.