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

Commit

Permalink
feat(select): match options by expression other than object identity
Browse files Browse the repository at this point in the history
Extend ng-options with a new clause, "track by [trackByExpression]", which can be used when
working with objects.  The `trackByExpression` should uniquely identify select options objects.
This solves the problem of previously having to match ng-options objects by identity.
You can now write: `ng-options="obj as obj.name for obj in objects track by obj.id"`
The "track by" expression will be used when checking for equality of objects.

Examples:
<select
    ng-model="user.favMovieStub"
    ng-options="movie as movie.name for movie in movies track by movie.id">
</select>

scope: {
  user: { name: 'Test user', favMovieStub: { id: 1, name: 'Starwars' } }
  movies: [{ id: 1, name: 'Starwars', rating: 5, ... }, { id: 13, ... }]
}

The select input will match user favMovieStub to the first movie in the movies array, and show
"Star Wars" as the selected item.
  • Loading branch information
quazzie authored and petebacondarwin committed May 14, 2013
1 parent 4acc28a commit c32a859
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 13 deletions.
60 changes: 48 additions & 12 deletions src/ng/directive/select.js
Expand Up @@ -39,7 +39,7 @@
* * `label` **`for`** `value` **`in`** `array`
* * `select` **`as`** `label` **`for`** `value` **`in`** `array`
* * `label` **`group by`** `group` **`for`** `value` **`in`** `array`
* * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array`
* * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array` **`track by`** `trackexpr`
* * for object data sources:
* * `label` **`for (`**`key` **`,`** `value`**`) in`** `object`
* * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object`
Expand All @@ -59,6 +59,9 @@
* element. If not specified, `select` expression will default to `value`.
* * `group`: The result of this expression will be used to group options using the `<optgroup>`
* DOM element.
* * `trackexpr`: Used when working with an array of objects. The result of this expression will be
* used to identify the objects in the array. The `trackexpr` will most likely refer to the
* `value` variable (e.g. `value.propertyName`).
*
* @example
<doc:example>
Expand Down Expand Up @@ -123,8 +126,8 @@

var ngOptionsDirective = valueFn({ terminal: true });
var selectDirective = ['$compile', '$parse', function($compile, $parse) {
//0000111110000000000022220000000000000000000000333300000000000000444444444444444440000000005555555555555555500000006666666666666666600000000000000077770
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/,
//0000111110000000000022220000000000000000000000333300000000000000444444444444444440000000005555555555555555500000006666666666666666600000000000000007777000000000000000000088888
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*?)(?:\s+track\s+by\s+(.*?))?$/,
nullModelCtrl = {$setViewValue: noop};

return {
Expand Down Expand Up @@ -298,7 +301,7 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {

if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) {
throw Error(
"Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" +
"Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_ (track by _expr_)?'" +
" but got '" + optionsExp + "'.");
}

Expand All @@ -308,6 +311,8 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
groupByFn = $parse(match[3] || ''),
valueFn = $parse(match[2] ? match[1] : valueName),
valuesFn = $parse(match[7]),
track = match[8],
trackFn = track ? $parse(match[8]) : null,
// This is an array of array of existing option groups in DOM. We try to reuse these if possible
// optionGroupsCache[0] is the options with no option group
// optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element
Expand Down Expand Up @@ -348,7 +353,14 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
if ((optionElement = optionGroup[index].element)[0].selected) {
key = optionElement.val();
if (keyName) locals[keyName] = key;
locals[valueName] = collection[key];
if (trackFn) {
for (var trackIndex = 0; trackIndex < collection.length; trackIndex++) {
locals[valueName] = collection[trackIndex];
if (trackFn(scope, locals) == key) break;
}
} else {
locals[valueName] = collection[key];
}
value.push(valueFn(scope, locals));
}
}
Expand All @@ -360,9 +372,19 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
} else if (key == ''){
value = null;
} else {
locals[valueName] = collection[key];
if (keyName) locals[keyName] = key;
value = valueFn(scope, locals);
if (trackFn) {
for (var trackIndex = 0; trackIndex < collection.length; trackIndex++) {
locals[valueName] = collection[trackIndex];
if (trackFn(scope, locals) == key) {
value = valueFn(scope, locals);
break;
}
}
} else {
locals[valueName] = collection[key];
if (keyName) locals[keyName] = key;
value = valueFn(scope, locals);
}
}
}
ctrl.$setViewValue(value);
Expand Down Expand Up @@ -394,7 +416,15 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
label;

if (multiple) {
selectedSet = new HashMap(modelValue);
if (trackFn && isArray(modelValue)) {
selectedSet = new HashMap([]);
for (var trackIndex = 0; trackIndex < modelValue.length; trackIndex++) {
locals[valueName] = modelValue[trackIndex];
selectedSet.put(trackFn(scope, locals), modelValue[trackIndex]);
}
} else {
selectedSet = new HashMap(modelValue);
}
}

// We now build up the list of options we need (we merge later)
Expand All @@ -406,15 +436,21 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
optionGroupNames.push(optionGroupName);
}
if (multiple) {
selected = selectedSet.remove(valueFn(scope, locals)) != undefined;
selected = selectedSet.remove(trackFn ? trackFn(scope, locals) : valueFn(scope, locals)) != undefined;
} else {
selected = modelValue === valueFn(scope, locals);
if (trackFn) {
var modelCast = {};
modelCast[valueName] = modelValue;
selected = trackFn(scope, modelCast) === trackFn(scope, locals);
} else {
selected = modelValue === valueFn(scope, locals);
}
selectedSet = selectedSet || selected; // see if at least one item is selected
}
label = displayFn(scope, locals); // what will be seen by the user
label = label === undefined ? '' : label; // doing displayFn(scope, locals) || '' overwrites zero values
optionGroup.push({
id: keyName ? keys[index] : index, // either the index into array or key from object
id: trackFn ? trackFn(scope, locals) : (keyName ? keys[index] : index), // either the index into array or key from object
label: label,
selected: selected // determine if we should be selected
});
Expand Down
33 changes: 32 additions & 1 deletion test/ng/directive/selectSpec.js
Expand Up @@ -495,7 +495,7 @@ describe('select', function() {
expect(function() {
compile('<select ng-model="selected" ng-options="i dont parse"></select>');
}).toThrow("Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in" +
" _collection_' but got 'i dont parse'.");
" _collection_ (track by _expr_)?' but got 'i dont parse'.");
});


Expand Down Expand Up @@ -753,6 +753,37 @@ 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'
});

scope.$apply(function() {
scope.values = [{id: 1, name: 'first'},
{id: 2, name: 'second'},
{id: 3, name: 'third'},
{id: 4, name: 'forth'}];
scope.selected = {id: 2};
});

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

var first = jqLite(element.find('option')[0]);
expect(first.text()).toEqual('first');
expect(first.attr('value')).toEqual('1');
var forth = jqLite(element.find('option')[3]);
expect(forth.text()).toEqual('forth');
expect(forth.attr('value')).toEqual('4');

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

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


it('should bind to scope value through experession', function() {
createSelect({
'ng-model': 'selected',
Expand Down

1 comment on commit c32a859

@icenine457
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So glad this made it in!

Please sign in to comment.