Skip to content

Commit 71ff194

Browse files
Narretzpetebacondarwin
authored andcommitted
feat(select): support values added by 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 angular#9842 Closes angular#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.
1 parent 41f3269 commit 71ff194

File tree

2 files changed

+243
-8
lines changed

2 files changed

+243
-8
lines changed

src/ng/directive/select.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ var SelectController =
1616
var self = this,
1717
optionsMap = new HashMap();
1818

19+
self.selectValueMap = {}; // Keys are the hashed values, values the original values
20+
1921
// If the ngModel doesn't get provided then provide a dummy noop version to prevent errors
2022
self.ngModelCtrl = noopNgModelController;
2123

@@ -47,7 +49,9 @@ var SelectController =
4749
// upon whether the select can have multiple values and whether ngOptions is at work.
4850
self.readValue = function readSingleValue() {
4951
self.removeUnknownOption();
50-
return $element.val();
52+
var val = $element.val();
53+
// ngValue added option values are stored in the selectValueMap, normal interpolations are not
54+
return val in self.selectValueMap ? self.selectValueMap[val] : val;
5155
};
5256

5357

@@ -106,9 +110,26 @@ var SelectController =
106110

107111
self.registerOption = function(optionScope, optionElement, optionAttrs, interpolateValueFn, interpolateTextFn) {
108112

109-
if (interpolateValueFn) {
113+
if (interpolateValueFn === true) {
114+
// The value attribute is set by ngValue
115+
var oldVal, hashedVal = NaN;
116+
optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
117+
if (isDefined(hashedVal)) {
118+
self.removeOption(oldVal);
119+
delete self.selectValueMap[hashedVal];
120+
}
121+
122+
hashedVal = hashKey(newVal);
123+
oldVal = newVal;
124+
self.addOption(newVal, optionElement);
125+
self.selectValueMap[hashedVal] = newVal;
126+
// Set the attribute directly instead of using optionAttrs.$set - this stops the observer
127+
// from firing a second time. Other $observers on value will also get the result of the
128+
// ngValue expression, not the hashed value
129+
optionElement.attr('value', hashedVal);
130+
});
131+
} else if (interpolateValueFn) {
110132
// The value attribute is interpolated
111-
var oldVal;
112133
optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
113134
if (isDefined(oldVal)) {
114135
self.removeOption(oldVal);
@@ -384,7 +405,8 @@ var selectDirective = function() {
384405
var array = [];
385406
forEach(element.find('option'), function(option) {
386407
if (option.selected) {
387-
array.push(option.value);
408+
var val = option.value;
409+
array.push(val in selectCtrl.selectValueMap ? selectCtrl.selectValueMap[val] : val);
388410
}
389411
});
390412
return array;
@@ -394,7 +416,7 @@ var selectDirective = function() {
394416
selectCtrl.writeValue = function writeMultipleValue(value) {
395417
var items = new HashMap(value);
396418
forEach(element.find('option'), function(option) {
397-
option.selected = isDefined(items.get(option.value));
419+
option.selected = isDefined(items.get(option.value)) || isDefined(items.get(selectCtrl.selectValueMap[option.value]));
398420
});
399421
};
400422

@@ -445,9 +467,13 @@ var optionDirective = ['$interpolate', function($interpolate) {
445467
restrict: 'E',
446468
priority: 100,
447469
compile: function(element, attr) {
448-
if (isDefined(attr.value)) {
470+
var interpolateValueFn;
471+
472+
if (isDefined(attr.ngValue)) {
473+
interpolateValueFn = true;
474+
} else if (isDefined(attr.value)) {
449475
// If the value attribute is defined, check if it contains an interpolation
450-
var interpolateValueFn = $interpolate(attr.value, true);
476+
interpolateValueFn = $interpolate(attr.value, true);
451477
} else {
452478
// If the value attribute is not defined then we fall back to the
453479
// text content of the option element, which may be interpolated

test/ng/directive/selectSpec.js

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
describe('select', function() {
4-
var scope, formElement, element, $compile, ngModelCtrl, selectCtrl, renderSpy;
4+
var scope, formElement, element, $compile, ngModelCtrl, selectCtrl, renderSpy, optionAttributesList = [];
55

66
function compile(html) {
77
formElement = jqLite('<form name="form">' + html + '</form>');
@@ -55,6 +55,18 @@ describe('select', function() {
5555
'</option>'
5656
};
5757
});
58+
59+
$compileProvider.directive('exposeAttributes', function() {
60+
return {
61+
require: '^^select',
62+
link: {
63+
pre: function(scope, element, attrs, ctrl) {
64+
optionAttributesList.push(attrs);
65+
}
66+
}
67+
};
68+
});
69+
5870
}));
5971

6072
beforeEach(inject(function($rootScope, _$compile_) {
@@ -1224,5 +1236,202 @@ describe('select', function() {
12241236
}).toThrowMinErr('ng','badname', 'hasOwnProperty is not a valid "option value" name');
12251237
});
12261238

1239+
describe('with ngValue (and non-primitive values)', function() {
1240+
1241+
they('should set the option attribute and select it for value $prop', [
1242+
'string',
1243+
undefined,
1244+
1,
1245+
true,
1246+
null,
1247+
{prop: 'value'},
1248+
['a'],
1249+
NaN
1250+
], function(prop) {
1251+
scope.option1 = prop;
1252+
scope.selected = 'NOMATCH';
1253+
1254+
compile('<select ng-model="selected">' +
1255+
'<option ng-value="option1">{{option1}}</option>' +
1256+
'</select>');
1257+
1258+
scope.$digest();
1259+
expect(element.find('option').eq(0).val()).toBe('? string:NOMATCH ?');
1260+
1261+
scope.selected = prop;
1262+
scope.$digest();
1263+
1264+
expect(element.find('option').eq(0).val()).toBe(hashKey(prop));
1265+
1266+
// Reset
1267+
scope.selected = false;
1268+
scope.$digest();
1269+
1270+
expect(element.find('option').eq(0).val()).toBe('? boolean:false ?');
1271+
1272+
browserTrigger(element.find('option').eq(0));
1273+
if (typeof prop === 'number' && isNaN(prop)) {
1274+
expect(scope.selected).toBeNaN();
1275+
} else {
1276+
expect(scope.selected).toBe(prop);
1277+
}
1278+
});
1279+
1280+
1281+
they('should update the option attribute and select it for value $prop', [
1282+
'string',
1283+
undefined,
1284+
1,
1285+
true,
1286+
null,
1287+
{prop: 'value'},
1288+
['a'],
1289+
NaN
1290+
], function(prop) {
1291+
scope.option = prop;
1292+
scope.selected = 'NOMATCH';
1293+
1294+
compile('<select ng-model="selected">' +
1295+
'<option ng-value="option">{{option}}</option>' +
1296+
'</select>');
1297+
1298+
var selectController = element.controller('select');
1299+
spyOn(selectController, 'removeOption').and.callThrough();
1300+
1301+
scope.$digest();
1302+
expect(selectController.removeOption).not.toHaveBeenCalled();
1303+
expect(element.find('option').eq(0).val()).toBe('? string:NOMATCH ?');
1304+
1305+
scope.selected = prop;
1306+
scope.$digest();
1307+
1308+
expect(element.find('option').eq(0).val()).toBe(hashKey(prop));
1309+
1310+
scope.option = 'UPDATEDVALUE';
1311+
scope.$digest();
1312+
1313+
expect(selectController.removeOption.calls.count()).toBe(1);
1314+
1315+
// Updating the option value currently does not update the select model
1316+
if (typeof prop === 'number' && isNaN(prop)) {
1317+
expect(selectController.removeOption.calls.argsFor(0)[0]).toBeNaN();
1318+
expect(scope.selected).toBeNaN();
1319+
} else {
1320+
expect(selectController.removeOption.calls.argsFor(0)[0]).toBe(prop);
1321+
expect(scope.selected).toBe(prop);
1322+
}
1323+
1324+
expect(element.find('option').eq(0).prop('selected')).toBe(true);
1325+
expect(element.find('option').eq(1).val()).toBe('string:UPDATEDVALUE');
1326+
1327+
scope.selected = 'UPDATEDVALUE';
1328+
scope.$digest();
1329+
1330+
// expect(element.find('option').eq(0).prop('selected')).toBe(true); not selected in Chrome?
1331+
expect(element.find('option').eq(0).val()).toBe('string:UPDATEDVALUE');
1332+
});
1333+
1334+
it('should interact with custom attribute $observe and $set calls', function() {
1335+
var log = [], optionAttr;
1336+
1337+
compile('<select ng-model="selected">' +
1338+
'<option expose-attributes ng-value="option">{{option}}</option>' +
1339+
'</select>');
1340+
1341+
optionAttr = optionAttributesList[0];
1342+
optionAttr.$observe('value', function(newVal) {
1343+
log.push(newVal);
1344+
});
1345+
1346+
scope.option = 'init';
1347+
scope.$digest();
1348+
1349+
expect(log[0]).toBe('init');
1350+
expect(element.find('option').eq(1).val()).toBe('string:init');
1351+
1352+
optionAttr.$set('value', 'update');
1353+
expect(log[1]).toBe('update');
1354+
expect(element.find('option').eq(1).val()).toBe('string:update');
1355+
1356+
});
1357+
1358+
describe('and select[multiple]', function() {
1359+
1360+
it('should allow multiple selection', function() {
1361+
scope.options = {
1362+
a: 'string',
1363+
b: undefined,
1364+
c: 1,
1365+
d: true,
1366+
e: null,
1367+
f: {prop: 'value'},
1368+
g: ['a'],
1369+
h: NaN
1370+
};
1371+
scope.selected = [];
1372+
1373+
compile('<select multiple ng-model="selected">' +
1374+
'<option ng-value="options.a">{{options.a}}</option>' +
1375+
'<option ng-value="options.b">{{options.b}}</option>' +
1376+
'<option ng-value="options.c">{{options.c}}</option>' +
1377+
'<option ng-value="options.d">{{options.d}}</option>' +
1378+
'<option ng-value="options.e">{{options.e}}</option>' +
1379+
'<option ng-value="options.f">{{options.f}}</option>' +
1380+
'<option ng-value="options.g">{{options.g}}</option>' +
1381+
'<option ng-value="options.h">{{options.h}}</option>' +
1382+
'</select>');
1383+
1384+
scope.$digest();
1385+
expect(element).toEqualSelect(
1386+
'string:string',
1387+
'undefined:undefined',
1388+
'number:1',
1389+
'boolean:true',
1390+
'object:null',
1391+
'object:4',
1392+
'object:5',
1393+
'number:NaN'
1394+
);
1395+
1396+
scope.selected = ['string', 1];
1397+
scope.$digest();
1398+
1399+
expect(element.find('option').eq(0).prop('selected')).toBe(true);
1400+
expect(element.find('option').eq(2).prop('selected')).toBe(true);
1401+
1402+
browserTrigger(element.find('option').eq(1));
1403+
expect(scope.selected).toEqual([undefined]);
1404+
1405+
//reset
1406+
scope.selected = [];
1407+
scope.$digest();
1408+
1409+
forEach(element.find('option'), function(option) {
1410+
// browserTrigger can't produce click + ctrl, so set selection manually
1411+
jqLite(option).prop('selected', true);
1412+
});
1413+
1414+
browserTrigger(element, 'change');
1415+
1416+
var arrayVal = ['a'];
1417+
arrayVal.$$hashKey = 'object:5';
1418+
1419+
expect(scope.selected).toEqual([
1420+
'string',
1421+
undefined,
1422+
1,
1423+
true,
1424+
null,
1425+
{prop: 'value', $$hashKey: 'object:4'},
1426+
arrayVal,
1427+
NaN
1428+
]);
1429+
});
1430+
1431+
});
1432+
1433+
1434+
});
1435+
12271436
});
12281437
});

0 commit comments

Comments
 (0)