diff --git a/benchmarks/select-ng-value-bp/app.js b/benchmarks/select-ng-value-bp/app.js
new file mode 100755
index 000000000000..033f55c23a27
--- /dev/null
+++ b/benchmarks/select-ng-value-bp/app.js
@@ -0,0 +1,104 @@
+"use strict";
+
+/* globals angular, benchmarkSteps */
+
+var app = angular.module('selectBenchmark', []);
+
+app.config(function($compileProvider) {
+ if ($compileProvider.debugInfoEnabled) {
+ $compileProvider.debugInfoEnabled(false);
+ }
+});
+
+
+
+app.controller('DataController', function($scope, $element) {
+ $scope.groups = [];
+ $scope.count = 10000;
+
+ function changeOptions() {
+ $scope.groups = [];
+ var i = 0;
+ var group;
+ while(i < $scope.count) {
+ if (i % 100 === 0) {
+ group = {
+ name: 'group-' + $scope.groups.length,
+ items: []
+ };
+ $scope.groups.push(group);
+ }
+ group.items.push({
+ id: i,
+ label: 'item-' + i
+ });
+ i++;
+ }
+ }
+
+ var selectElement = $element.find('select');
+ console.log(selectElement);
+
+
+ benchmarkSteps.push({
+ name: 'add-options',
+ fn: function() {
+ $scope.$apply(function() {
+ $scope.count = 10000;
+ changeOptions();
+ });
+ }
+ });
+
+ benchmarkSteps.push({
+ name: 'set-model-1',
+ fn: function() {
+ $scope.$apply(function() {
+ $scope.x = $scope.groups[10].items[0];
+ });
+ }
+ });
+
+ benchmarkSteps.push({
+ name: 'set-model-2',
+ fn: function() {
+ $scope.$apply(function() {
+ $scope.x = $scope.groups[0].items[10];
+ });
+ }
+ });
+
+ benchmarkSteps.push({
+ name: 'remove-options',
+ fn: function() {
+ $scope.count = 100;
+ changeOptions();
+ }
+ });
+
+ benchmarkSteps.push({
+ name: 'add-options',
+ fn: function() {
+ $scope.$apply(function() {
+ $scope.count = 10000;
+ changeOptions();
+ });
+ }
+ });
+
+ benchmarkSteps.push({
+ name: 'set-view-1',
+ fn: function() {
+ selectElement.val('2000');
+ selectElement.triggerHandler('change');
+ }
+ });
+
+ benchmarkSteps.push({
+ name: 'set-view-2',
+ fn: function() {
+ selectElement.val('1000');
+ selectElement.triggerHandler('change');
+ }
+ });
+});
diff --git a/benchmarks/select-ng-value-bp/bp.conf.js b/benchmarks/select-ng-value-bp/bp.conf.js
new file mode 100755
index 000000000000..bf543bb2cef7
--- /dev/null
+++ b/benchmarks/select-ng-value-bp/bp.conf.js
@@ -0,0 +1,11 @@
+module.exports = function(config) {
+ config.set({
+ scripts: [ {
+ id: 'angular',
+ src: '/build/angular.js'
+ },
+ {
+ src: 'app.js',
+ }]
+ });
+};
diff --git a/benchmarks/select-ng-value-bp/main.html b/benchmarks/select-ng-value-bp/main.html
new file mode 100755
index 000000000000..273027615288
--- /dev/null
+++ b/benchmarks/select-ng-value-bp/main.html
@@ -0,0 +1,15 @@
+
+
+
+
+ Tests the execution of a select with ngRepeat'ed options with ngValue for rendering during model
+ and option updates.
+
+
+
+ {{a.label}}
+
+
+
+
+
diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js
index 982a8882f270..df7af367e2d3 100644
--- a/src/ng/directive/input.js
+++ b/src/ng/directive/input.js
@@ -1743,10 +1743,8 @@ var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/;
* `ngValue` is useful when dynamically generating lists of radio buttons using
* {@link ngRepeat `ngRepeat`}, as shown below.
*
- * Likewise, `ngValue` can be used to generate `` elements for
- * the {@link select `select`} element. In that case however, only strings are supported
- * for the `value `attribute, so the resulting `ngModel` will always be a string.
- * Support for `select` models with non-string values is available via `ngOptions`.
+ * Likewise, `ngValue` can be used to set the value of ` ` elements for
+ * the {@link select `select`} element.
*
* @element input
* @param {string=} ngValue angular expression, whose value will be bound to the `value` attribute
diff --git a/src/ng/directive/ngOptions.js b/src/ng/directive/ngOptions.js
index fffac750dd14..10d970097f02 100644
--- a/src/ng/directive/ngOptions.js
+++ b/src/ng/directive/ngOptions.js
@@ -15,13 +15,12 @@ var ngOptionsMinErr = minErr('ngOptions');
* elements for the `` element using the array or object obtained by evaluating the
* `ngOptions` comprehension expression.
*
- * In many cases, `ngRepeat` can be used on `` elements instead of `ngOptions` to achieve a
- * similar result. However, `ngOptions` provides some benefits such as reducing memory and
- * increasing speed by not creating a new scope for each repeated instance, as well as providing
- * more flexibility in how the ``'s model is assigned via the `select` **`as`** part of the
- * comprehension expression. `ngOptions` should be used when the `` model needs to be bound
- * to a non-string value. This is because an option element can only be bound to string values at
- * present.
+ * In many cases, `ngRepeat` can be used on `` elements instead of {@link ng.directive:ngOptions
+ * ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits:
+ * - more flexibility in how the ``'s model is assigned via the `select` **`as`** part of the
+ * comprehension expression
+ * - reduced memory consumption by not creating a new scope for each repeated instance
+ * - increased render speed by creating the options in a documentFragment instead of individually
*
* When an item in the `` menu is selected, the array element or object property
* represented by the selected option will be bound to the model identified by the `ngModel`
diff --git a/src/ng/directive/select.js b/src/ng/directive/select.js
index 6c71ebcaf276..b583546d653f 100644
--- a/src/ng/directive/select.js
+++ b/src/ng/directive/select.js
@@ -16,8 +16,11 @@ var SelectController =
var self = this,
optionsMap = new HashMap();
+ self.selectValueMap = {}; // Keys are the hashed values, values the original values
+
// If the ngModel doesn't get provided then provide a dummy noop version to prevent errors
self.ngModelCtrl = noopNgModelController;
+ self.multiple = false;
// The "unknown" option is one that is prepended to the list if the viewValue
// does not match any of the options. When it is rendered the value of the unknown
@@ -33,6 +36,12 @@ var SelectController =
$element.val(unknownVal);
};
+ self.updateUnknownOption = function(val) {
+ var unknownVal = '? ' + hashKey(val) + ' ?';
+ self.unknownOption.val(unknownVal);
+ $element.val(unknownVal);
+ };
+
$scope.$on('$destroy', function() {
// disable unknown option so that we don't do work when the whole select is being destroyed
self.renderUnknownOption = noop;
@@ -46,8 +55,15 @@ var SelectController =
// Read the value of the select control, the implementation of this changes depending
// upon whether the select can have multiple values and whether ngOptions is at work.
self.readValue = function readSingleValue() {
- self.removeUnknownOption();
- return $element.val();
+ var val = $element.val();
+ // ngValue added option values are stored in the selectValueMap, normal interpolations are not
+ var realVal = val in self.selectValueMap ? self.selectValueMap[val] : val;
+
+ if (self.hasOption(realVal)) {
+ return realVal;
+ }
+
+ return null;
};
@@ -56,12 +72,16 @@ var SelectController =
self.writeValue = function writeSingleValue(value) {
if (self.hasOption(value)) {
self.removeUnknownOption();
- $element.val(value);
+ var hashedVal = hashKey(value);
+ $element.val(hashedVal in self.selectValueMap ? hashedVal : value);
+
if (value === '') self.emptyOption.prop('selected', true); // to make IE9 happy
} else {
if (value == null && self.emptyOption) {
self.removeUnknownOption();
$element.val('');
+ } else if (self.unknownOption.parent().length) {
+ self.updateUnknownOption(value);
} else {
self.renderUnknownOption(value);
}
@@ -80,7 +100,9 @@ var SelectController =
}
var count = optionsMap.get(value) || 0;
optionsMap.put(value, count + 1);
- self.ngModelCtrl.$render();
+ // Only render at the end of a digest. This improves render performance when many options
+ // are added during a digest and ensures all relevant options are correctly marked as selected
+ scheduleRender();
};
// Tell the select control that an option, with the given value, has been removed
@@ -104,35 +126,131 @@ var SelectController =
};
+ var renderScheduled = false;
+ function scheduleRender() {
+ if (renderScheduled) return;
+ renderScheduled = true;
+ $scope.$$postDigest(function() {
+ renderScheduled = false;
+ self.ngModelCtrl.$render();
+ });
+ }
+
+ var updateScheduled = false;
+ function scheduleViewValueUpdate(renderAfter) {
+ if (updateScheduled) return;
+
+ updateScheduled = true;
+
+ $scope.$$postDigest(function() {
+ updateScheduled = false;
+ self.ngModelCtrl.$setViewValue(self.readValue());
+ if (renderAfter) self.ngModelCtrl.$render();
+ });
+ }
+
+
self.registerOption = function(optionScope, optionElement, optionAttrs, interpolateValueFn, interpolateTextFn) {
- if (interpolateValueFn) {
+ if (optionAttrs.$attr.ngValue) {
+ // The value attribute is set by ngValue
+ var oldVal, hashedVal = NaN;
+ optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
+
+ var removal;
+ var previouslySelected = optionElement.prop('selected');
+
+ if (isDefined(hashedVal)) {
+ self.removeOption(oldVal);
+ delete self.selectValueMap[hashedVal];
+ removal = true;
+ }
+
+ hashedVal = hashKey(newVal);
+ oldVal = newVal;
+ self.selectValueMap[hashedVal] = newVal;
+ self.addOption(newVal, optionElement);
+ // Set the attribute directly instead of using optionAttrs.$set - this stops the observer
+ // from firing a second time. Other $observers on value will also get the result of the
+ // ngValue expression, not the hashed value
+ optionElement.attr('value', hashedVal);
+
+ if (removal && previouslySelected) {
+ scheduleViewValueUpdate();
+ }
+
+ });
+ } else if (interpolateValueFn) {
// The value attribute is interpolated
- var oldVal;
optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
+ var currentVal = self.readValue();
+ var removal;
+ var previouslySelected = optionElement.prop('selected');
+ var removedVal;
+
if (isDefined(oldVal)) {
self.removeOption(oldVal);
+ removal = true;
+ removedVal = oldVal;
}
oldVal = newVal;
self.addOption(newVal, optionElement);
+
+ if (removal && previouslySelected) {
+ scheduleViewValueUpdate();
+ }
});
} else if (interpolateTextFn) {
// The text content is interpolated
optionScope.$watch(interpolateTextFn, function interpolateWatchAction(newVal, oldVal) {
optionAttrs.$set('value', newVal);
+ var previouslySelected = optionElement.prop('selected');
if (oldVal !== newVal) {
self.removeOption(oldVal);
}
self.addOption(newVal, optionElement);
+
+ if (oldVal && previouslySelected) {
+ scheduleViewValueUpdate();
+ }
});
} else {
// The value attribute is static
self.addOption(optionAttrs.value, optionElement);
}
+
+ var oldDisabled;
+ optionAttrs.$observe('disabled', function(newVal) {
+
+ // Since model updates will also select disabled options (like ngOptions),
+ // we only have to handle options becoming disabled, not enabled
+
+ if (newVal === 'true' || newVal && optionElement.prop('selected')) {
+ if (self.multiple) {
+ scheduleViewValueUpdate(true);
+ } else {
+ self.ngModelCtrl.$setViewValue(null);
+ self.ngModelCtrl.$render();
+ }
+ oldDisabled = newVal;
+ }
+ });
+
optionElement.on('$destroy', function() {
- self.removeOption(optionAttrs.value);
+ var currentValue = self.readValue();
+ var removeValue = optionAttrs.value;
+
+ self.removeOption(removeValue);
self.ngModelCtrl.$render();
+
+ if (self.multiple && currentValue && currentValue.indexOf(removeValue) !== -1 ||
+ currentValue === removeValue
+ ) {
+ // When multiple (selected) options are destroyed at the same time, we don't want
+ // to run a model update for each of them. Instead, run a single update in the $$postDigest
+ scheduleViewValueUpdate(true);
+ }
});
};
}];
@@ -143,7 +261,7 @@ var SelectController =
* @restrict E
*
* @description
- * HTML `SELECT` element with angular data-binding.
+ * HTML `select` element with angular data-binding.
*
* The `select` directive is used together with {@link ngModel `ngModel`} to provide data-binding
* between the scope and the `` control (including setting default values).
@@ -153,14 +271,24 @@ var SelectController =
* When an item in the `` menu is selected, the value of the selected option will be bound
* to the model identified by the `ngModel` directive. With static or repeated options, this is
* the content of the `value` attribute or the textContent of the ``, if the value attribute is missing.
- * If you want dynamic value attributes, you can use interpolation inside the value attribute.
+ * Value and textContent can be interpolated.
*
- *
- * Note that the value of a `select` directive used without `ngOptions` is always a string.
- * When the model needs to be bound to a non-string value, you must either explicitly convert it
- * using a directive (see example below) or use `ngOptions` to specify the set of options.
- * This is because an option element can only be bound to string values at present.
- *
+ * ## Matching model and option values
+ *
+ * In general, the match between the model and an option is evaluated by strictly comparing the model
+ * value against the value of the available options.
+ *
+ * If you are setting the option value with the option's `value` attribute, or textContent, the
+ * value will always be a `string` which means that the model value must also be a string.
+ * Otherwise the `select` directive cannot match them correctly.
+ *
+ * To bind the model to a non-string value, you can use one of the following strategies:
+ * - the {@link ng.ngOptions `ngOptions`} directive
+ * ({@link ng.select#using-select-with-ngoptions-and-setting-a-default-value})
+ * - the {@link ng.ngValue `ngValue`} directive, which allows arbitrary expressions to be
+ * option values ({@link ng.select#using-ngvalue-to-bind-the-model-to-an-array-of-objects Example})
+ * - model $parsers / $formatters to convert the string value
+ * ({@link ng.select#binding-select-to-a-non-string-value-via-ngmodel-parsing-formatting Example})
*
* If the viewValue of `ngModel` does not match any of the options, then the control
* will automatically add an "unknown" option, which it then removes when the mismatch is resolved.
@@ -169,13 +297,17 @@ var SelectController =
* be nested into the `` element. This element will then represent the `null` or "not selected"
* option. See example below for demonstration.
*
- *
+ * ## Choosing between `ngRepeat` and `ngOptions`
+ *
* In many cases, `ngRepeat` can be used on `` elements instead of {@link ng.directive:ngOptions
- * ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits, such as
- * more flexibility in how the ``'s model is assigned via the `select` **`as`** part of the
- * comprehension expression, and additionally in reducing memory and increasing speed by not creating
- * a new scope for each repeated instance.
- *
+ * ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits:
+ * - more flexibility in how the ``'s model is assigned via the `select` **`as`** part of the
+ * comprehension expression
+ * - reduced memory consumption by not creating a new scope for each repeated instance
+ * - increased render speed by creating the options in a documentFragment instead of individually
+ *
+ * Specifically, select with repeated options slows down significantly starting at 2000 options in
+ * Chrome and Internet Explorer / Edge.
*
*
* @param {string} ngModel Assignable angular expression to data-bind to.
@@ -241,24 +373,24 @@ var SelectController =
*
*
* ### Using `ngRepeat` to generate `select` options
- *
+ *
*
*
*
*
- * repeatSelect = {{data.repeatSelect}}
+ * model = {{data.model}}
*
*
*
* angular.module('ngrepeatSelect', [])
* .controller('ExampleController', ['$scope', function($scope) {
* $scope.data = {
- * repeatSelect: null,
+ * model: null,
* availableOptions: [
* {id: '1', name: 'Option A'},
* {id: '2', name: 'Option B'},
@@ -269,6 +401,37 @@ var SelectController =
*
*
*
+ * ### Using `ngValue` to bind the model to an array of objects
+ *
+ *
+ *
+ *
+ *
+ *
model = {{data.model | json}}
+ *
+ *
+ *
+ * angular.module('ngvalueSelect', [])
+ * .controller('ExampleController', ['$scope', function($scope) {
+ * $scope.data = {
+ * model: null,
+ * availableOptions: [
+ {value: 'myString', name: 'string'},
+ {value: 1, name: 'integer'},
+ {value: true, name: 'boolean'},
+ {value: null, name: 'null'},
+ {value: {prop: 'value'}, name: 'object'},
+ {value: ['a'], name: 'array'}
+ * ]
+ * };
+ * }]);
+ *
+ *
*
* ### Using `select` with `ngOptions` and setting a default value
* See the {@link ngOptions ngOptions documentation} for more `ngOptions` usage examples.
@@ -368,6 +531,7 @@ var selectDirective = function() {
// to the `readValue` method, which can be changed if the select can have multiple
// selected values or if the options are being generated by `ngOptions`
element.on('change', function() {
+ selectCtrl.removeUnknownOption();
scope.$apply(function() {
ngModelCtrl.$setViewValue(selectCtrl.readValue());
});
@@ -378,13 +542,15 @@ var selectDirective = function() {
// we have to add an extra watch since ngModel doesn't work well with arrays - it
// doesn't trigger rendering if only an item in the array changes.
if (attr.multiple) {
+ selectCtrl.multiple = true;
// Read value now needs to check each option to see if it is selected
selectCtrl.readValue = function readMultipleValue() {
var array = [];
forEach(element.find('option'), function(option) {
- if (option.selected) {
- array.push(option.value);
+ if (option.selected && !option.disabled) {
+ var val = option.value;
+ array.push(val in selectCtrl.selectValueMap ? selectCtrl.selectValueMap[val] : val);
}
});
return array;
@@ -394,7 +560,7 @@ var selectDirective = function() {
selectCtrl.writeValue = function writeMultipleValue(value) {
var items = new HashMap(value);
forEach(element.find('option'), function(option) {
- option.selected = isDefined(items.get(option.value));
+ option.selected = isDefined(items.get(option.value)) || isDefined(items.get(selectCtrl.selectValueMap[option.value]));
});
};
@@ -445,13 +611,18 @@ var optionDirective = ['$interpolate', function($interpolate) {
restrict: 'E',
priority: 100,
compile: function(element, attr) {
- if (isDefined(attr.value)) {
+ var interpolateValueFn, interpolateTextFn;
+
+ if (isDefined(attr.ngValue)) {
+ // jshint noempty: false
+ // Will be handled by registerOption
+ } else if (isDefined(attr.value)) {
// If the value attribute is defined, check if it contains an interpolation
- var interpolateValueFn = $interpolate(attr.value, true);
+ interpolateValueFn = $interpolate(attr.value, true);
} else {
// If the value attribute is not defined then we fall back to the
// text content of the option element, which may be interpolated
- var interpolateTextFn = $interpolate(element.text(), true);
+ interpolateTextFn = $interpolate(element.text(), true);
if (!interpolateTextFn) {
attr.$set('value', element.text());
}
diff --git a/test/ng/directive/selectSpec.js b/test/ng/directive/selectSpec.js
index 980906c60f1d..9027aa657326 100644
--- a/test/ng/directive/selectSpec.js
+++ b/test/ng/directive/selectSpec.js
@@ -1,7 +1,7 @@
'use strict';
describe('select', function() {
- var scope, formElement, element, $compile, ngModelCtrl, selectCtrl, renderSpy;
+ var scope, formElement, element, $compile, ngModelCtrl, selectCtrl, renderSpy, optionAttributesList = [];
function compile(html) {
formElement = jqLite('');
@@ -55,6 +55,18 @@ describe('select', function() {
' '
};
});
+
+ $compileProvider.directive('exposeAttributes', function() {
+ return {
+ require: '^^select',
+ link: {
+ pre: function(scope, element, attrs, ctrl) {
+ optionAttributesList.push(attrs);
+ }
+ }
+ };
+ });
+
}));
beforeEach(inject(function($rootScope, _$compile_) {
@@ -297,7 +309,7 @@ describe('select', function() {
expect(selectCtrl.writeValue).not.toHaveBeenCalled();
scope.$digest();
- expect(selectCtrl.writeValue).toHaveBeenCalledOnce();
+ expect(selectCtrl.writeValue).toHaveBeenCalled();
dealoc(select);
});
@@ -617,20 +629,27 @@ describe('select', function() {
scope.$apply(function() {
scope.robots.pop();
});
- expect(element).toEqualSelect([unknownValue('r2d2')], 'c3p0');
- expect(scope.robot).toBe('r2d2');
+ expect(element).toEqualSelect([unknownValue(null)], 'c3p0');
+ expect(scope.robot).toBe(null);
scope.$apply(function() {
scope.robots.unshift('r2d2');
});
+ expect(element).toEqualSelect([unknownValue(null)], 'r2d2', 'c3p0');
+ expect(scope.robot).toBe(null);
+
+ scope.$apply(function() {
+ scope.robot = 'r2d2';
+ });
+
expect(element).toEqualSelect(['r2d2'], 'c3p0');
- expect(scope.robot).toBe('r2d2');
scope.$apply(function() {
delete scope.robots;
});
- expect(element).toEqualSelect([unknownValue('r2d2')]);
- expect(scope.robot).toBe('r2d2');
+
+ expect(element).toEqualSelect([unknownValue(null)]);
+ expect(scope.robot).toBe(null);
});
});
@@ -1224,5 +1243,955 @@ describe('select', function() {
}).toThrowMinErr('ng','badname', 'hasOwnProperty is not a valid "option value" name');
});
+ describe('with ngValue (and non-primitive values)', function() {
+
+ they('should set the option attribute and select it for value $prop', [
+ 'string',
+ undefined,
+ 1,
+ true,
+ null,
+ {prop: 'value'},
+ ['a'],
+ NaN
+ ], function(prop) {
+ scope.option1 = prop;
+ scope.selected = 'NOMATCH';
+
+ compile('' +
+ '{{option1}} ' +
+ ' ');
+
+ scope.$digest();
+ expect(element.find('option').eq(0).val()).toBe('? string:NOMATCH ?');
+
+ scope.selected = prop;
+ scope.$digest();
+
+ expect(element.find('option').eq(0).val()).toBe(hashKey(prop));
+
+ // Reset
+ scope.selected = false;
+ scope.$digest();
+
+ expect(element.find('option').eq(0).val()).toBe('? boolean:false ?');
+
+ browserTrigger(element.find('option').eq(0));
+ if (typeof prop === 'number' && isNaN(prop)) {
+ expect(scope.selected).toBeNaN();
+ } else {
+ expect(scope.selected).toBe(prop);
+ }
+ });
+
+
+ they('should update the option attribute and select it for value $prop', [
+ 'string',
+ undefined,
+ 1,
+ true,
+ null,
+ {prop: 'value'},
+ ['a'],
+ NaN
+ ], function(prop) {
+ scope.option = prop;
+ scope.selected = 'NOMATCH';
+
+ compile('' +
+ '{{option}} ' +
+ ' ');
+
+ var selectController = element.controller('select');
+ spyOn(selectController, 'removeOption').and.callThrough();
+
+ scope.$digest();
+ expect(selectController.removeOption).not.toHaveBeenCalled();
+ expect(element.find('option').eq(0).val()).toBe('? string:NOMATCH ?');
+
+ scope.selected = prop;
+ scope.$digest();
+
+ expect(element.find('option').eq(0).val()).toBe(hashKey(prop));
+ expect(element[0].selectedIndex).toBe(0);
+
+ scope.option = 'UPDATEDVALUE';
+ scope.$digest();
+
+ expect(selectController.removeOption.calls.count()).toBe(1);
+
+ // Updating the option value currently does not update the select model
+ if (typeof prop === 'number' && isNaN(prop)) {
+ expect(selectController.removeOption.calls.argsFor(0)[0]).toBeNaN();
+ } else {
+ expect(selectController.removeOption.calls.argsFor(0)[0]).toBe(prop);
+ }
+
+ expect(scope.selected).toBe(null);
+ expect(element[0].selectedIndex).toBe(0);
+ expect(element.find('option').length).toBe(2);
+ expect(element.find('option').eq(0).prop('selected')).toBe(true);
+ expect(element.find('option').eq(0).val()).toBe(unknownValue(prop));
+ expect(element.find('option').eq(1).prop('selected')).toBe(false);
+ expect(element.find('option').eq(1).val()).toBe('string:UPDATEDVALUE');
+
+ scope.selected = 'UPDATEDVALUE';
+ scope.$digest();
+
+ expect(element[0].selectedIndex).toBe(0);
+ expect(element.find('option').eq(0).val()).toBe('string:UPDATEDVALUE');
+ });
+
+ it('should interact with custom attribute $observe and $set calls', function() {
+ var log = [], optionAttr;
+
+ compile('' +
+ '{{option}} ' +
+ ' ');
+
+ optionAttr = optionAttributesList[0];
+ optionAttr.$observe('value', function(newVal) {
+ log.push(newVal);
+ });
+
+ scope.option = 'init';
+ scope.$digest();
+
+ expect(log[0]).toBe('init');
+ expect(element.find('option').eq(1).val()).toBe('string:init');
+
+ optionAttr.$set('value', 'update');
+ expect(log[1]).toBe('update');
+ expect(element.find('option').eq(1).val()).toBe('string:update');
+
+ });
+
+ it('should ignore the option text / value attribute if the ngValue attribute exists', function() {
+ scope.ngvalue = 'abc';
+ scope.value = 'def';
+ scope.textvalue = 'ghi';
+
+ compile('{{textvalue}} ');
+ expect(element).toEqualSelect([unknownValue(undefined)], 'string:abc');
+ });
+
+ it('should ignore option text with multiple interpolations if the ngValue attribute exists', function() {
+ scope.ngvalue = 'abc';
+ scope.textvalue = 'def';
+ scope.textvalue2 = 'ghi';
+
+ compile('{{textvalue}} {{textvalue2}} ');
+ expect(element).toEqualSelect([unknownValue(undefined)], 'string:abc');
+ });
+
+ describe('and select[multiple]', function() {
+
+ it('should allow multiple selection', function() {
+ scope.options = {
+ a: 'string',
+ b: undefined,
+ c: 1,
+ d: true,
+ e: null,
+ f: {prop: 'value'},
+ g: ['a'],
+ h: NaN
+ };
+ scope.selected = [];
+
+ compile('' +
+ '{{options.a}} ' +
+ '{{options.b}} ' +
+ '{{options.c}} ' +
+ '{{options.d}} ' +
+ '{{options.e}} ' +
+ '{{options.f}} ' +
+ '{{options.g}} ' +
+ '{{options.h}} ' +
+ ' ');
+
+ scope.$digest();
+ expect(element).toEqualSelect(
+ 'string:string',
+ 'undefined:undefined',
+ 'number:1',
+ 'boolean:true',
+ 'object:null',
+ 'object:4',
+ 'object:5',
+ 'number:NaN'
+ );
+
+ scope.selected = ['string', 1];
+ scope.$digest();
+
+ expect(element.find('option').eq(0).prop('selected')).toBe(true);
+ expect(element.find('option').eq(2).prop('selected')).toBe(true);
+
+ browserTrigger(element.find('option').eq(1));
+ expect(scope.selected).toEqual([undefined]);
+
+ //reset
+ scope.selected = [];
+ scope.$digest();
+
+ forEach(element.find('option'), function(option) {
+ // browserTrigger can't produce click + ctrl, so set selection manually
+ jqLite(option).prop('selected', true);
+ });
+
+ browserTrigger(element, 'change');
+
+ var arrayVal = ['a'];
+ arrayVal.$$hashKey = 'object:5';
+
+ expect(scope.selected).toEqual([
+ 'string',
+ undefined,
+ 1,
+ true,
+ null,
+ {prop: 'value', $$hashKey: 'object:4'},
+ arrayVal,
+ NaN
+ ]);
+ });
+
+ });
+
+ });
+
+ describe('updating the model and selection when option elements are manipulated', function() {
+
+ they('should set the model to null when the currently selected option with $prop is removed',
+ ['ngValue', 'interpolatedValue', 'interpolatedText'], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ browserTrigger(optionElements.eq(0));
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toBe(prop === 'ngValue' ? A : 'A');
+
+ scope.options.shift();
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toBe(null);
+ expect(element.val()).toBe('? object:null ?');
+ });
+
+
+ they('should set the model to null when the currently selected option with $prop changes its value',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ browserTrigger(optionElements.eq(0));
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toBe('A');
+
+ A.name = 'X';
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toBe(null);
+ expect(element.val()).toBe('? string:A ?');
+ });
+
+
+ they('should set the model to null when the currently selected option with $prop is disabled',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ browserTrigger(optionElements.eq(0));
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toBe('A');
+
+ A.disabled = true;
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toBe(null);
+ expect(element.val()).toBe('? object:null ?');
+ });
+
+
+ they('should select a disabled option with $prop when the model is set to the matching value',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(optionElements[0].value).toEqual(unknownValue(undefined));
+
+ B.disabled = true;
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(optionElements[0].value).toEqual(unknownValue(undefined));
+
+ scope.obj.value = 'B';
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toBe('B');
+ // jQuery returns null for val() when the option is disabled, see
+ // https://bugs.jquery.com/ticket/13097
+ expect(element[0].value).toBe(prop === 'ngValue' ? 'string:B' : 'B');
+ expect(optionElements.eq(1).prop('selected')).toBe(true);
+ });
+
+
+ they('should ignore an option with $prop that becomes enabled and does not match the model',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ browserTrigger(optionElements.eq(0));
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toBe('A');
+
+ A.disabled = true;
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toBe(null);
+ expect(element.val()).toBe('? object:null ?');
+
+ A.disabled = false;
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toBe(null);
+ expect(element.val()).toBe('? object:null ?');
+ });
+
+
+ they('should select a newly added option with $prop when it matches the current model',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B];
+ scope.obj = {
+ value: prop === 'ngValue' ? C : 'C'
+ };
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+
+ scope.options.push(C);
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(element.val()).toBe(prop === 'ngValue' ? 'object:4' : 'C');
+ expect(optionElements.length).toEqual(3);
+ expect(optionElements[2].selected).toBe(true);
+ expect(scope.obj.value).toEqual(prop === 'ngValue' ? {name: 'C', $$hashKey: 'object:4'} : 'C');
+ });
+
+
+ they('should keep selection and model when repeated options with track by are replaced with equal options',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {
+ value: 'C'
+ };
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+
+ scope.obj.value = 'C';
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(element.val()).toBe(prop === 'ngValue' ? 'string:C' : 'C');
+ expect(optionElements.length).toEqual(3);
+ expect(optionElements[2].selected).toBe(true);
+ expect(scope.obj.value).toBe('C');
+
+ scope.options = [
+ {name: 'A'},
+ {name: 'B'},
+ {name: 'C'}
+ ];
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(element.val()).toBe(prop === 'ngValue' ? 'string:C' : 'C');
+ expect(optionElements.length).toEqual(3);
+ expect(optionElements[2].selected).toBe(true);
+ expect(scope.obj.value).toBe('C');
+ });
+
+ describe('when multiple', function() {
+
+ they('should set the model to null when the currently selected option with $prop is removed',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var ngModelCtrl = element.controller('ngModel');
+ var ngModelCtrlSpy = spyOn(ngModelCtrl, '$setViewValue').and.callThrough();
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+
+ optionElements.eq(0).prop('selected', true);
+ optionElements.eq(2).prop('selected', true);
+ browserTrigger(element);
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toEqual(prop === 'ngValue' ? [A, C] : ['A', 'C']);
+
+
+ ngModelCtrlSpy.calls.reset();
+ scope.options.shift();
+ scope.options.pop();
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(1);
+ expect(scope.obj.value).toEqual([]);
+ expect(element.val()).toBe(null);
+ expect(ngModelCtrlSpy).toHaveBeenCalledTimes(1);
+ });
+
+ they('should set the model to null when the currently selected option with $prop changes its value',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var ngModelCtrl = element.controller('ngModel');
+ var ngModelCtrlSpy = spyOn(ngModelCtrl, '$setViewValue').and.callThrough();
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+
+ optionElements.eq(0).prop('selected', true);
+ optionElements.eq(2).prop('selected', true);
+ browserTrigger(element);
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toEqual(['A', 'C']);
+
+ ngModelCtrlSpy.calls.reset();
+ A.name = 'X';
+ C.name = 'Z';
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+ expect(scope.obj.value).toEqual([]);
+ expect(element.val()).toBe(null);
+ expect(ngModelCtrlSpy).toHaveBeenCalledTimes(1);
+
+ });
+
+ they('should set the model to null when the currently selected option with $prop becomes disabled',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'}, D = { name: 'D'};
+
+ scope.options = [A, B, C, D];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var ngModelCtrl = element.controller('ngModel');
+ var ngModelCtrlSpy = spyOn(ngModelCtrl, '$setViewValue').and.callThrough();
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+
+ optionElements.eq(0).prop('selected', true);
+ optionElements.eq(2).prop('selected', true);
+ optionElements.eq(3).prop('selected', true);
+ browserTrigger(element);
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toEqual(['A', 'C', 'D']);
+
+ ngModelCtrlSpy.calls.reset();
+ A.disabled = true;
+ C.disabled = true;
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toEqual(['D']);
+ expect(element.val()).toEqual(prop === 'ngValue' ? ['string:D'] : ['D']);
+ expect(ngModelCtrlSpy).toHaveBeenCalledTimes(1);
+ });
+
+
+ they('should select disabled options with $prop when the model is set to matching values',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'}, D = {name: 'D'};
+
+ scope.options = [A, B, C, D];
+ scope.obj = {};
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(element[0].value).toBe('');
+
+ A.disabled = true;
+ D.disabled = true;
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(element[0].value).toBe('');
+
+ scope.obj.value = prop === 'ngValue' ? [A, C, D] : ['A', 'C', 'D'];
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(optionElements.length).toEqual(4);
+ expect(scope.obj.value).toEqual(prop === 'ngValue' ?
+ [
+ {name: 'A', $$hashKey: 'object:4', disabled: true},
+ {name: 'C', $$hashKey: 'object:6'},
+ {name: 'D', $$hashKey: 'object:7', disabled: true}
+ ] :
+ ['A', 'C', 'D']
+ );
+
+ expect(optionElements.eq(0).prop('selected')).toBe(true);
+ expect(optionElements.eq(2).prop('selected')).toBe(true);
+ expect(optionElements.eq(3).prop('selected')).toBe(true);
+ });
+
+ they('should select a newly added option with $prop when it matches the current model',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B];
+ scope.obj = {
+ value: prop === 'ngValue' ? [B, C] : ['B', 'C']
+ };
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(2);
+ expect(optionElements.eq(1).prop('selected')).toBe(true);
+
+ scope.options.push(C);
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(element.val()).toEqual(prop === 'ngValue' ? ['object:4', 'object:5'] : ['B', 'C']);
+ expect(optionElements.length).toEqual(3);
+ expect(optionElements[1].selected).toBe(true);
+ expect(optionElements[2].selected).toBe(true);
+ expect(scope.obj.value).toEqual(prop === 'ngValue' ?
+ [{ name: 'B', $$hashKey: 'object:4'},
+ {name: 'C', $$hashKey: 'object:5'}] :
+ ['B', 'C']);
+ });
+
+ they('should keep selection and model when a repeated options with track by are replaced with equal options',
+ [
+ 'ngValue',
+ 'interpolatedValue',
+ 'interpolatedText'
+ ], function(prop) {
+
+ var A = { name: 'A'}, B = { name: 'B'}, C = { name: 'C'};
+
+ scope.options = [A, B, C];
+ scope.obj = {
+ value: 'C'
+ };
+
+ var optionString = '';
+
+ switch (prop) {
+ case 'ngValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedValue':
+ optionString = '{{$index}} ';
+ break;
+ case 'interpolatedText':
+ optionString = '{{option.name}} ';
+ break;
+ }
+
+ compile(
+ '' +
+ optionString +
+ ' '
+ );
+
+ var optionElements = element.find('option');
+ expect(optionElements.length).toEqual(3);
+
+ scope.obj.value = ['B', 'C'];
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(element.val()).toEqual(prop === 'ngValue' ? ['string:B', 'string:C'] : ['B', 'C']);
+ expect(optionElements.length).toEqual(3);
+ expect(optionElements[1].selected).toBe(true);
+ expect(optionElements[2].selected).toBe(true);
+ expect(scope.obj.value).toEqual(['B', 'C']);
+
+ scope.options = [
+ {name: 'A'},
+ {name: 'B'},
+ {name: 'C'}
+ ];
+ scope.$digest();
+
+ optionElements = element.find('option');
+ expect(element.val()).toEqual(prop === 'ngValue' ? ['string:B', 'string:C'] : ['B', 'C']);
+ expect(optionElements.length).toEqual(3);
+ expect(optionElements[1].selected).toBe(true);
+ expect(optionElements[2].selected).toBe(true);
+ expect(scope.obj.value).toEqual(['B', 'C']);
+ });
+
+ });
+
+ });
+
+
});
});