feat(select): support values of any type added with ngValue

select elements with ngModel will now set ngModel to option values added by ngValue.
This allows setting values of any type (not only strings) without the use of ngOptions.

Interpolations inside attributes can only be strings, but the ngValue directive uses attrs.$set,
which does not have any type restriction. Any $observe on the value attribute will therefore receive
the original value (result of ngValue expression). However, when a user selects an option, the browser
sets the select value to the actual option's value attribute, which is still always a string.
For that reason, when options are added by ngValue, we set the hashed value of the original value in
the value attribute and store the actual value in an extra map. When the select value changes, we
read access the actual value via the hashed select value.

Since we only use a hashed value for ngValue, we will have extra checks for the hashed values:
- when options are read, for both single and multiple select
- when options are written, for multiple select

I don't expect this to have a performance impact, but it should be kept in mind.

Closes #9842
Closes #6297

BREAKING CHANGE:

`<option>` elements added to `<select ng-model>` via `ngValue` now add their values in hash form, i.e.
`<option ng-value="myString">` becomes `<option ng-value="myString" value="string:myString">`.

This is done to support binding options with values of any type to selects.

This should rarely affect applications, as the values of options are usually not relevant to the
application logic, but it's possible that option values are checked in tests.
This commit is contained in:
Martin Staffa
2016-02-06 02:08:28 +01:00
committed by Martin Staffa
parent 47fbbabe0b
commit f02b707b5e
4 changed files with 374 additions and 41 deletions
+2 -4
View File
@@ -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 `<option>` 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 `<option>` elements for
* the {@link select `select`} element.
*
* @element input
* @param {string=} ngValue angular expression, whose value will be bound to the `value` attribute
+6 -7
View File
@@ -15,13 +15,12 @@ var ngOptionsMinErr = minErr('ngOptions');
* elements for the `<select>` element using the array or object obtained by evaluating the
* `ngOptions` comprehension expression.
*
* In many cases, `ngRepeat` can be used on `<option>` 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 `<select>`'s model is assigned via the `select` **`as`** part of the
* comprehension expression. `ngOptions` should be used when the `<select>` 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 `<option>` elements instead of {@link ng.directive:ngOptions
* ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits:
* - more flexibility in how the `<select>`'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 `<select>` menu is selected, the array element or object property
* represented by the selected option will be bound to the model identified by the `ngModel`
+133 -28
View File
@@ -16,6 +16,8 @@ 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;
@@ -46,8 +48,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,7 +65,9 @@ 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) {
@@ -104,11 +115,53 @@ var SelectController =
};
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) {
if (isDefined(oldVal)) {
self.removeOption(oldVal);
@@ -143,7 +196,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 `<select>` control (including setting default values).
@@ -153,14 +206,24 @@ var SelectController =
* When an item in the `<select>` 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 `<option>`, 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.
*
* <div class="alert alert-warning">
* 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.
* </div>
* ## 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 +232,17 @@ var SelectController =
* be nested into the `<select>` element. This element will then represent the `null` or "not selected"
* option. See example below for demonstration.
*
* <div class="alert alert-info">
* ## Choosing between `ngRepeat` and `ngOptions`
*
* In many cases, `ngRepeat` can be used on `<option>` 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 `<select>`'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.
* </div>
* ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits:
* - more flexibility in how the `<select>`'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 +308,24 @@ var SelectController =
*</example>
*
* ### Using `ngRepeat` to generate `select` options
* <example name="ngrepeat-select" module="ngrepeatSelect">
* <example name="select-ngrepeat" module="ngrepeatSelect">
* <file name="index.html">
* <div ng-controller="ExampleController">
* <form name="myForm">
* <label for="repeatSelect"> Repeat select: </label>
* <select name="repeatSelect" id="repeatSelect" ng-model="data.repeatSelect">
* <select name="repeatSelect" id="repeatSelect" ng-model="data.model">
* <option ng-repeat="option in data.availableOptions" value="{{option.id}}">{{option.name}}</option>
* </select>
* </form>
* <hr>
* <tt>repeatSelect = {{data.repeatSelect}}</tt><br/>
* <tt>model = {{data.model}}</tt><br/>
* </div>
* </file>
* <file name="app.js">
* 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 +336,37 @@ var SelectController =
* </file>
*</example>
*
* ### Using `ngValue` to bind the model to an array of objects
* <example name="select-ngvalue" module="ngvalueSelect">
* <file name="index.html">
* <div ng-controller="ExampleController">
* <form name="myForm">
* <label for="ngvalueselect"> ngvalue select: </label>
* <select size="6" name="ngvalueselect" ng-model="data.model" multiple>
* <option ng-repeat="option in data.availableOptions" ng-value="option.value">{{option.name}}</option>
* </select>
* </form>
* <hr>
* <pre>model = {{data.model | json}}</pre><br/>
* </div>
* </file>
* <file name="app.js">
* 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'}
* ]
* };
* }]);
* </file>
*</example>
*
* ### Using `select` with `ngOptions` and setting a default value
* See the {@link ngOptions ngOptions documentation} for more `ngOptions` usage examples.
@@ -368,6 +466,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());
});
@@ -384,7 +483,8 @@ var selectDirective = function() {
var array = [];
forEach(element.find('option'), function(option) {
if (option.selected) {
array.push(option.value);
var val = option.value;
array.push(val in selectCtrl.selectValueMap ? selectCtrl.selectValueMap[val] : val);
}
});
return array;
@@ -394,7 +494,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 +545,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());
}
+233 -2
View File
@@ -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('<form name="form">' + html + '</form>');
@@ -55,6 +55,18 @@ describe('select', function() {
'</option>'
};
});
$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);
});
@@ -1224,5 +1236,224 @@ 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('<select ng-model="selected">' +
'<option ng-value="option1">{{option1}}</option>' +
'</select>');
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('<select ng-model="selected">' +
'<option ng-value="option">{{option}}</option>' +
'</select>');
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('<select ng-model="selected">' +
'<option expose-attributes ng-value="option">{{option}}</option>' +
'</select>');
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('<select ng-model="x"><option ng-value="ngvalue" value="{{value}}">{{textvalue}}</option></select>');
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('<select ng-model="x"><option ng-value="ngvalue">{{textvalue}} {{textvalue2}}</option></select>');
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('<select multiple ng-model="selected">' +
'<option ng-value="options.a">{{options.a}}</option>' +
'<option ng-value="options.b">{{options.b}}</option>' +
'<option ng-value="options.c">{{options.c}}</option>' +
'<option ng-value="options.d">{{options.d}}</option>' +
'<option ng-value="options.e">{{options.e}}</option>' +
'<option ng-value="options.f">{{options.f}}</option>' +
'<option ng-value="options.g">{{options.g}}</option>' +
'<option ng-value="options.h">{{options.h}}</option>' +
'</select>');
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
]);
});
});
});
});
});