Angular.js: ng-options track by and select as are not compatible

Created on 6 Mar 2014  路  39Comments  路  Source: angular/angular.js

When both a track by expression and a select as expression are used in ng-options, binding doesn't work anymore.

For instance:

      it('should bind to scope value through experession while tracking/identifying objects', 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 = scope.values[0].id;
        });

        expect(element.val()).toEqual('10');

        scope.$apply(function() {
          scope.selected = scope.values[1].id;
        });

        expect(element.val()).toEqual('20');
      });

It seems that in the ngOptions directive, the track function always directly use the value expression even if a select as expression is set.

forms low inconvenient bug

Most helpful comment

Ok, I have investigated this. It's not actually a bug, just misleading documentation.
The thing is, you just can't combine value as label for collection with track by.
You have to pick the one or the other. There are two different Use Cases:

1. You want a shiny presentation for an ugly value

Use value as label:

items = [
  {value: 1, label: 'One'},
  {value: 2, label: 'Two'},
]
template = 'ng-options="item.value as item.label for items"'

When you change the dropdown, the model is assigned whatever you define in front of the as.

2. You want to iterate over complex objects

Use track by:

items = [
  {id: 1, ...},
  {id: 2, ...},
  {id: 3, ...},
]
template = 'ng-options="item as item.label for items track by items.id"'

When you change the dropdown, the model is assigned the item. The track by expression is only used to associate options with items through the value attribute on the <option>.

Illustrated in http://jsfiddle.net/0vpsv1wa/1/

All 39 comments

Oops, accidentally deleted my previous comment.
I created/modified a fiddle to illustrate the problem:
http://jsfiddle.net/qWzTb/418/

+1 I'm having the same problem.

It's annoying but you can really just leave away the track by and it'll work.

Can confirm this problem still exists as of v1.3.0-beta.15.

Having the same problem with 1.2.20 :/

Same here

I've been encountering this again.
The problem was created in c32a859bdb93699cc080f9affed4bcff63005a64 by @quazzie:

For checking if an option is selected, the trackFn is used, where the valueFn should be used instead. I'll try to come up with a fix.

Ok, I have investigated this. It's not actually a bug, just misleading documentation.
The thing is, you just can't combine value as label for collection with track by.
You have to pick the one or the other. There are two different Use Cases:

1. You want a shiny presentation for an ugly value

Use value as label:

items = [
  {value: 1, label: 'One'},
  {value: 2, label: 'Two'},
]
template = 'ng-options="item.value as item.label for items"'

When you change the dropdown, the model is assigned whatever you define in front of the as.

2. You want to iterate over complex objects

Use track by:

items = [
  {id: 1, ...},
  {id: 2, ...},
  {id: 3, ...},
]
template = 'ng-options="item as item.label for items track by items.id"'

When you change the dropdown, the model is assigned the item. The track by expression is only used to associate options with items through the value attribute on the <option>.

Illustrated in http://jsfiddle.net/0vpsv1wa/1/

Faced the same problem, and figured a workaround, but I don't know if this is going to solve future issues (at least in the project I'm doing this).

Somewhere on your code add this ugly script.

$(document).on('change', 'select', function(e){        
        var scope = angular.element($(this)).scope();
        var val   = scope[$(this).attr('name')];   
        $(this).val(val);
});

Interestingly, if I remove track-by from the original test, the expectations still fail:

'ng-options': 'item.id as item.name for item in values'

Expected '0' to equal '10
Expected '1' to equal '20'

@jeffbcross: This is "expected", since with ngOptions the select will use the option's index as value (for the <option> element) and not for example item.id.
This is understandable, since the modelValue could be an object.

The test should rather be:

it('should bind to scope value through experession while tracking/identifying objects', function() {
  createSelect({
    'ng-model': 'selected',
    'ng-options': 'item.id as item.name for item in values track by item.id'
  });

  var selectedIdx;

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

  expect(element.val()).toEqual('' + selectedIdx);

  scope.$apply(function() {
    selectedIdx = 1;
    scope.selected = scope.values[selectedIdx].id;
  });

  expect(element.val()).toEqual('' + selectedIdx);
});

(And removing track by ... makes it pass as expected.)

But @gkalpak what is the point of using select as expression if select just uses the index of the option as the value anyway? @IgorMinar and I have been looking through tests and implementation of this and are a little confused. It seems like select as isn't really working as documented. I would expect this:

scope.$apply(function() {
  scope.values = [{id: 10, name: 'A'}];
  scope.selected = 10;
});

expect(element.val()).toBe('10');

Am I misunderstanding how select as is supposed to work?

@jeffbcross: If I am not mistaken, the point of select as is to be able to have modelValues that are not restricted to being strings (as is the case when using "tranditional" <option> elements ("by hand" or via ngRepeat)).

So, the value of the <option> elements are indices (in order to ensure that they can be represented as strings) and the specified select value is bound to the modelValue (if there ngModel is present).

E.g.:

<select ng-model="someObject" ng-options="item as item.label for item in items"></select>

// with:
$scope.items = [
    {id: 1, label: 'Item 1', someProp: 'someValue1', someFunc: function () {}},
    {id: 2, label: 'Item 2', someProp: 'someValue2', someFunc: function () {}},
    ....
];

will:

  1. display Item 1, Item 2, ... as <option> textContent
  2. bind 0, 1, ... as <option> values
  3. bind $scope.someObject with the item corresponding to the selected <option> (i.e. index)

This way, it is possible to assign "non-stringifiable" objects to the model, while still having unique, distinguishable values for the <option> elements.

@gkalpak thanks for the detail. Your expression behaves exactly the same way if you would remove the item as part, because ngOptions by default uses value as select.

it('should give index of object as value', function() {
  createSelect({
    'ng-model': 'selected',
    'ng-options': 'item.label for item in values'
  });

  scope.$apply(function() {
    scope.values = [{label: 'foo'}];
    scope.selected = scope.values[0];
  });

  expect(element.val()).toBe('0');
  expect(element.children().eq(0).text()).toBe('foo');
  expect(scope.selected).toBe(scope.values[0]);
});

@jeffbcross : Indeed. Just to be clear though, you don't have to use the item itself. You can use a property of the item, which can be an object also. E.g.:

<select ng-model="someObject" ng-options="item.prop as item.label for item in items"></select>

// with:
$scope.items = [
    {id: 1, label: 'Item 1', prop: {key1: 'val1.1', key2: 'val1.2'}},
    {id: 2, label: 'Item 2', prop: {key1: 'val2.1', key2: 'val2.2'}},
    ....
];

Ahh, I see. Thanks for the clarification. We should document that better :). Unless someone (@IgorMinar) has a valid use case for element.val() needing to return the actual expression result, we should probably not change anything with select as.

I'll focus back on finding the source of the original issue now...

Hi,

Is this will be fixed in the next release?

<select ng-model="company.user_id" ng-options="(person.first_name +' '+ person.last_name) for person in salesperson track by person.id"></select>
$scope.salesperson = [
    {
        id: 5,
        first_name: 'John',
        last_name: 'Doe'
    },
    {
        id: 10,
        first_name: 'Jane',
        last_name: 'Doe'
    }
];

$scope.company.user_id = 10; // OK but the UI isn't updated (v1.3.0-rc.4)
$scope.company.user_id = $scope.salesperson[1]; // OK but needs a manual loop to retrieve '1'

Bests

Here is a WIP build with a fix for these issues (and some other issues with ngOptions): https://gist.github.com/jeffbcross/7c3e7efa22283bfc3c1c

I created a plnkr based on @janv's jsfiddle, and the reported issue appears to be fixed.

@janv @damiengenet @SQiShER @ahoereth @wojciechfornal @jsanta @matheusvalim would you mind testing this build with your code to see if the issue is resolved?

We ended up deciding that supporting these two expressions together is not feasible, and so we are throwing an error when used in combination. These expressions are fundamentally incompatible because it is not possible to reliably and consistently determine the parent value of a model, since select as can assign any child of a value as the model value.

Prior to refactorings in this release, neither of these expressions worked correctly independently,
and did not work at all when combined. @tbosch and I added many more tests and refactored most of the ngOptions logic in ab354cf04ef8277a87d1ea1e79f9d63876fa97c8 and aad60953ce1c68433b6ae74b6a62a55f80a45e6e. So the good news is, ngOptions should be working more consistently for everyone now (although this may cause breaking changes since folks probably have code that's expecting broken behavior).

This breaking change was frustrating for me, as I found there were a few places in my code that relied on having different expressions for select as and track by. Primarily, this was using the track by expression to match up between the ng-model value and the correct option in the list of options (which might have been fetched from the database, for example). Since they were different objects, the track by enabled Angular to know which option in the list to select.

If other people are as frustrated as me by this change, then the following directive that I created may be helpful for you. Essentially, it enables you to restore that functionality of track by if you are relying on the select as in your ng-options expression by effectively combining the two:

``` javascript:
/**

  • ng-options-track-by-fix directive. Add as an attribute to selects using an ng-options expression that relied on the
  • select as / track by combination which was removed in AngularJS version 1.3.0-rc.5. (https://github.com/angular/angular.js/issues/6564)
  • This adds a trackByFix(trackByExpression, selectValue) function to the scope for use in the ng-options
  • expression.
    *
  • e.g. old:

  • *
  • new:

  • *
  • Note: The first argument to the trackByFix() function should be a string which was the old track by expression.
    *
  • You can customise the name of the trackByFix function by specifying a value for the ng-options-track-by-fix attribute.
  • You will need to use this if you have multiple
    /
    app.directive('ngOptionsTrackByFix', ['$parse', function($parse) {
    return {
    restrict: 'A',
    require: ['select', 'ngModel'],
    link: function($scope, $element, $attrs) {
    if (!$attrs.ngOptions) {
    console.warn('Cannot use ng-options-track-by-fix if not using ng-options');
    return;
    }
    var valueExpression = $attrs.ngOptions.replace(/.
    for\s+(\w+)\s+in\s+.*/gi, '$1');
    if (!valueExpression) {
    console.warn('Could not determine value expression from ng-options: ' + $attrs.ngOptions);
    return;
    }
    var modelValueGetter = $parse($attrs.ngModel);
        var trackByFixFnName = $attrs.ngOptionsTrackByFix || 'trackByFix';

        if ($scope[trackByFixFnName]) {
            throw new Error('There is already a $scope.' + trackByFixFnName + ' defined, you will need to specify ' +
            'a different name for the ng-options-track-by-fix attribute and use that name instead of trackByFix.');
        }
        $scope[trackByFixFnName] = function trackByFix(trackByExpr, option) {
            var trackByFn = $parse(trackByExpr),
                modelValue = modelValueGetter($scope);
            if (trackByFn($scope, getLocals(option)) === trackByFn($scope, getLocals(modelValue)))
                return modelValue;
            else
                return option;
        };

        function getLocals(value) {
            var locals = {};
            locals[valueExpression] = value;
            return locals;
        }
    }
};

}]);
```

Hi,
could you give a more concrete example how you used to use track by together with select as?

Because I think they are generally not compatible. See this example (from https://github.com/angular/angular.js/blob/master/docs/content/error/ngOptions/trkslct.ngdoc):

<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 used to calculate whether an item is selected, even when it was reloaded from the server. This can only be done in the following way:

  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}, which does not include a property id.

Hi @tbosch,

I have constructed a Plunkr example that illustrates the main place I was relying on both select as and track by to make things easier.

Essentially, I have an array of options that contains properties that are used for the group by label, and a value property which is the object actually bound as the model value.

I would then have a track by expression that would work for both the subobject and the option in the array.

So the example you cited could be fixed to use this technique to have:
ng-options="item.subItem as item.label for item in values track by (item.subitem.name || item.name)".
This should then work correctly.

Is this not a valid use case?

@greglockwood I forked the plnkr and changed the expression to work with rc.5. I just changed ng-model to to be data and removed the selectAs. I can imagine use cases of shared models where this could cause some inconvenience though.

<!--Before-->
<select ng-model="data.value" ng-options="opt.value as opt.display group by opt.groupName for opt in options track by (opt.id || opt.value.id)"></select>
<!--After-->
<select ng-model="data" ng-options="opt.display group by opt.groupName for opt in options track by (opt.id || opt.value.id)"></select>

Your track by expression is one that was select as-aware and would actually work. Unfortunately, there's no way we can know at runtime if the track expression is compatible with model and option value. It's worth considering something less restrictive than a thrown error, like a "I know what I'm doing" opt-out, or just logging a warning. @tbosch what do you think?

Reopening so we can give this some more consideration. After some discussion with @tbosch and @IgorMinar, here are some proposed approaches to allowing track by and select as to work together:

  • BREAKING CHANGE: Set evaluation context for track-by to value that is set to ng-model. In the example two comments above, track by would be "name", and would evaluate against the subItems, since this is what was set to the model via select as.
  • Add a magic $selectedValue object, which would be the encouraged variable to use in track by expressions. We could deprecate accessing item and log a warning when it is accessed, and in a future version we could change this warning to throw an error.
  • Leave as is

Changing the milestone since 1.3.0-rc.5 is already released.

We ended up deciding to just make the old behavior possible. Working on a PR

@jeffbcross This is a tricky one. Whilst I am grateful for undoing the change to make the old behaviour possible, I agree it was confusing. In particular, that you have to construct an expression for the track by that works for both types of context is not obvious, and was a pain point for me when developing that part of the application I am working on.

In response to your fork of my plnkr, I offer you a counter-fork which demonstrates something closer to my actual use case, in that it is indeed using a "shared model". In such a case, it is not valid to overwrite the parent object with the complete option selected, as the dbObject and each opt share a common structure only in terms of the value property.

As for your proposed solutions, I don't think either of them is the perfect solution, as they both have their downsides.

The first is probably the better of the two, though. Whilst it is a breaking change, it is not a huge deal to cope with, as most people would just find themselves deleting <identifier>. from their track by expressions to adapt.

My main misgiving about this change is that the context for the track by expression would be different to the context for the majority of the other subexpressions in the greater ng-options expression.

Here is a graphic to illustrate what I mean:

image

As you can see, there would be 4 different contexts within a single ng-options expression with this change:

  1. the current scope for the <select>, shown in blue
  2. the context of what is effectively a child scope of the select's scope, with a single property on it for each option, shown in red
  3. the ng-model expression value, shown in green
  4. each option in the array, shown in green

In particular, that the track by expression is evaluated against two different contexts is reasonably without precedent AFAIK, and potentially confusing.

The cons of the second solution, introducing a $selectedValue magic value for use in track by, include:

  • Having to remember exactly what it is called. "Is it $selectValue, or was it $selectedValue, or was it just $selected or $value? Darnit, let me check the docs again!"
  • Inconsistency with the way track by works with ng-repeat (this con is shared by the first solution too).
  • I cannot see a valid use case where you would _not_ need to use $selectedValue in your track by expression, which effectively makes it redundant.
  • Lack of backwards compatibility (unless I am not fully understanding something correctly)

The biggest thing in its favour is that it is an _explicit context_, which is potentially less confusing for people and it is easier to document and explain it as a special "double purpose" context. It would also be fairly easy to migrate to.

Anyways, those are just my thoughts (with input from a talented co-worker).

Thanks for bringing the issue up, and thanks for the feedback on the approaches. This is a tough problem without a clean solution that satisfies all constraints, is consistent with ng-repeat, and is backwards-compatible. In the meantime, I removed the thrown error, added tests for how track by and select as should work together, and fixed the implementation.

Unfortunately, the originally reported issue would not work with the expression/model as is, but could work with some re-working.

This is still happening in v1.4.7, no fix yet?

same problem here!!

Same problem!!

same thing here with in v1.4.7

Same thing with v1.4.8

Same thing with v1.5.3

The ng-options works fine, but when update the model manual, the select value hadn't update.

<select ng-model="shiny"
                ng-options="opt.value as opt.label for opt in options">
            </select>

$scope.complex = 2;

See the http://jsfiddle.net/0vpsv1wa/110/

There are certain (documented) limitations in using track by together with select ... as.
Not all usecases are supported.

ng-options="item.id as item.name for item in values track by item.id" works, but doesn't se the default value
http://plnkr.co/edit/6YdeJviPnCwY3Em9ltyo?p=preview

No, it doesn't work (i.e. track by has no effect).

Was this page helpful?
0 / 5 - 0 ratings