Skip to content

Commit 58de3f5

Browse files
committed
fix(select): re-define ngModelCtrl.$render in the select postLink fn
Previously, the $render function was re-defined in the select directive's preLink function. When a select element is compiled, every option element inside it is linked and registered with the selectCtrl, which calls $render to update the selected option.$render calls selectCtrl.writeValue, which adds an unknown option in case no option is selected. In cases where optgroup elements are followed by a line-break, adding the unknown option confuses the html compiler and makes it call the link function of the following option with a wrong element, which means this option is not correctly registered. Since manipulation of the DOM in the preLink function is wrong API usage, the problem cannot be fixed in the compiler. With this commit, the $render function is not re-defined until the select postLink function, at which point all option elements have been linked already. The commit also changes the toEqualSelectWithOptions matcher to take selected options in groups into account. Closes angular#13583
1 parent dec8a0e commit 58de3f5

File tree

2 files changed

+106
-33
lines changed

2 files changed

+106
-33
lines changed

src/ng/directive/select.js

+19-8
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,8 @@ var selectDirective = function() {
356356
controller: SelectController,
357357
priority: 1,
358358
link: {
359-
pre: selectPreLink
359+
pre: selectPreLink,
360+
post: selectPostLink
360361
}
361362
};
362363

@@ -370,13 +371,6 @@ var selectDirective = function() {
370371

371372
selectCtrl.ngModelCtrl = ngModelCtrl;
372373

373-
// We delegate rendering to the `writeValue` method, which can be changed
374-
// if the select can have multiple selected values or if the options are being
375-
// generated by `ngOptions`
376-
ngModelCtrl.$render = function() {
377-
selectCtrl.writeValue(ngModelCtrl.$viewValue);
378-
};
379-
380374
// When the selected item(s) changes we delegate getting the value of the select control
381375
// to the `readValue` method, which can be changed if the select can have multiple
382376
// selected values or if the options are being generated by `ngOptions`
@@ -430,6 +424,23 @@ var selectDirective = function() {
430424

431425
}
432426
}
427+
428+
function selectPostLink(scope, element, attrs, ctrls) {
429+
// if ngModel is not defined, we don't need to do anything
430+
var ngModelCtrl = ctrls[1];
431+
if (!ngModelCtrl) return;
432+
433+
var selectCtrl = ctrls[0];
434+
435+
// We delegate rendering to the `writeValue` method, which can be changed
436+
// if the select can have multiple selected values or if the options are being
437+
// generated by `ngOptions`.
438+
// This must be done in the postLink fn to prevent $render to be called before
439+
// all nodes have been linked correctly.
440+
ngModelCtrl.$render = function() {
441+
selectCtrl.writeValue(ngModelCtrl.$viewValue);
442+
};
443+
}
433444
};
434445

435446

test/ng/directive/selectSpec.js

+87-25
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;
4+
var scope, formElement, element, $compile, ngModelCtrl, selectCtrl, renderSpy;
55

66
function compile(html) {
77
formElement = jqLite('<form name="form">' + html + '</form>');
@@ -10,10 +10,42 @@ describe('select', function() {
1010
scope.$apply();
1111
}
1212

13+
function compileRepeatedOptions() {
14+
compile('<select ng-model="robot">' +
15+
'<option value="{{item.value}}" ng-repeat="item in robots">{{item.label}}</option>' +
16+
'</select>');
17+
}
18+
19+
function compileGroupedOptions() {
20+
compile(
21+
'<select ng-model="mySelect">' +
22+
'<option ng-repeat="item in values">{{item.name}}</option>' +
23+
'<optgroup ng-repeat="group in groups" label="{{group.name}}">' +
24+
'<option ng-repeat="item in group.values">{{item.name}}</option>' +
25+
'</optgroup>' +
26+
'</select>');
27+
}
28+
1329
function unknownValue(value) {
1430
return '? ' + hashKey(value) + ' ?';
1531
}
1632

33+
beforeEach(module(function($compileProvider) {
34+
$compileProvider.directive('spyOnWriteValue', function() {
35+
return {
36+
require: 'select',
37+
link: {
38+
pre: function(scope, element, attrs, ctrl) {
39+
selectCtrl = ctrl;
40+
renderSpy = jasmine.createSpy('renderSpy');
41+
selectCtrl.ngModelCtrl.$render = renderSpy.andCallFake(selectCtrl.ngModelCtrl.$render);
42+
spyOn(selectCtrl, 'writeValue').andCallThrough();
43+
}
44+
}
45+
};
46+
});
47+
}));
48+
1749
beforeEach(inject(function($rootScope, _$compile_) {
1850
scope = $rootScope.$new(); //create a child scope because the root scope can't be $destroy-ed
1951
$compile = _$compile_;
@@ -47,12 +79,14 @@ describe('select', function() {
4779
toEqualSelectWithOptions: function(expected) {
4880
var actualValues = {};
4981
var optionGroup;
82+
var optionValue;
5083

5184
forEach(this.actual.find('option'), function(option) {
5285
optionGroup = option.parentNode.label || '';
5386
actualValues[optionGroup] = actualValues[optionGroup] || [];
5487
// IE9 doesn't populate the label property from the text property like other browsers
55-
actualValues[optionGroup].push(option.label || option.text);
88+
optionValue = option.label || option.text;
89+
actualValues[optionGroup].push(option.selected ? [optionValue] : optionValue);
5690
});
5791

5892
this.message = function() {
@@ -198,6 +232,50 @@ describe('select', function() {
198232
});
199233

200234

235+
it('should select options in a group when there is a linebreak before an option', function() {
236+
scope.mySelect = 'B';
237+
scope.$apply();
238+
239+
var select = jqLite(
240+
'<select ng-model="mySelect">' +
241+
'<optgroup label="first">' +
242+
'<option value="A">A</option>' +
243+
'</optgroup>' +
244+
'<optgroup label="second">' + '\n' +
245+
'<option value="B">B</option>' +
246+
'</optgroup> ' +
247+
'</select>');
248+
249+
$compile(select)(scope);
250+
scope.$apply();
251+
252+
expect(select).toEqualSelectWithOptions({'first':['A'], 'second': [['B']]});
253+
dealoc(select);
254+
});
255+
256+
257+
it('should only call selectCtrl.writeValue after a digest has occured', function() {
258+
scope.mySelect = 'B';
259+
scope.$apply();
260+
261+
var select = jqLite(
262+
'<select spy-on-write-value ng-model="mySelect">' +
263+
'<optgroup label="first">' +
264+
'<option value="A">A</option>' +
265+
'</optgroup>' +
266+
'<optgroup label="second">' + '\n' +
267+
'<option value="B">B</option>' +
268+
'</optgroup> ' +
269+
'</select>');
270+
271+
$compile(select)(scope);
272+
expect(selectCtrl.writeValue).not.toHaveBeenCalled();
273+
274+
scope.$digest();
275+
expect(selectCtrl.writeValue).toHaveBeenCalledOnce();
276+
dealoc(select);
277+
});
278+
201279
describe('empty option', function() {
202280

203281
it('should allow empty option to be added and removed dynamically', function() {
@@ -538,22 +616,6 @@ describe('select', function() {
538616

539617
describe('selectController.hasOption', function() {
540618

541-
function compileRepeatedOptions() {
542-
compile('<select ng-model="robot">' +
543-
'<option value="{{item.value}}" ng-repeat="item in robots">{{item.label}}</option>' +
544-
'</select>');
545-
}
546-
547-
function compileGroupedOptions() {
548-
compile(
549-
'<select ng-model="mySelect">' +
550-
'<option ng-repeat="item in values">{{item.name}}</option>' +
551-
'<optgroup ng-repeat="group in groups" label="{{group.name}}">' +
552-
'<option ng-repeat="item in group.values">{{item.name}}</option>' +
553-
'</optgroup>' +
554-
'</select>');
555-
}
556-
557619
describe('flat options', function() {
558620
it('should return false for options shifted via ngRepeat', function() {
559621
scope.robots = [
@@ -624,7 +686,7 @@ describe('select', function() {
624686
expect(selectCtrl.hasOption('A')).toBe(true);
625687
expect(selectCtrl.hasOption('B')).toBe(true);
626688
expect(selectCtrl.hasOption('C')).toBe(true);
627-
expect(element).toEqualSelectWithOptions({'': ['A', 'B', 'C']});
689+
expect(element).toEqualSelectWithOptions({'': ['A', 'B', ['C']]});
628690
});
629691
});
630692

@@ -665,7 +727,7 @@ describe('select', function() {
665727
expect(selectCtrl.hasOption('C')).toBe(true);
666728
expect(selectCtrl.hasOption('D')).toBe(true);
667729
expect(selectCtrl.hasOption('E')).toBe(true);
668-
expect(element).toEqualSelectWithOptions({'': [''], 'first':['B', 'C'], 'second': ['D', 'E']});
730+
expect(element).toEqualSelectWithOptions({'': [['']], 'first':['B', 'C'], 'second': ['D', 'E']});
669731
});
670732

671733

@@ -702,7 +764,7 @@ describe('select', function() {
702764
expect(selectCtrl.hasOption('C')).toBe(true);
703765
expect(selectCtrl.hasOption('D')).toBe(true);
704766
expect(selectCtrl.hasOption('E')).toBe(true);
705-
expect(element).toEqualSelectWithOptions({'': [''], 'first':['B', 'C', 'D'], 'second': ['E']});
767+
expect(element).toEqualSelectWithOptions({'': [['']], 'first':['B', 'C', 'D'], 'second': ['E']});
706768
});
707769

708770

@@ -741,7 +803,7 @@ describe('select', function() {
741803
expect(selectCtrl.hasOption('D')).toBe(true);
742804
expect(selectCtrl.hasOption('E')).toBe(true);
743805
expect(selectCtrl.hasOption('F')).toBe(false);
744-
expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C'], 'second': ['D', 'E']});
806+
expect(element).toEqualSelectWithOptions({'': [[''], 'A'], 'first':['B', 'C'], 'second': ['D', 'E']});
745807
});
746808

747809

@@ -778,7 +840,7 @@ describe('select', function() {
778840
expect(selectCtrl.hasOption('C')).toBe(false);
779841
expect(selectCtrl.hasOption('D')).toBe(true);
780842
expect(selectCtrl.hasOption('E')).toBe(true);
781-
expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'D'], 'second': ['E']});
843+
expect(element).toEqualSelectWithOptions({'': [[''], 'A'], 'first':['B', 'D'], 'second': ['E']});
782844
});
783845

784846

@@ -813,7 +875,7 @@ describe('select', function() {
813875
expect(selectCtrl.hasOption('C')).toBe(true);
814876
expect(selectCtrl.hasOption('D')).toBe(false);
815877
expect(selectCtrl.hasOption('E')).toBe(true);
816-
expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C'], 'second': ['E']});
878+
expect(element).toEqualSelectWithOptions({'': [[''], 'A'], 'first':['B', 'C'], 'second': ['E']});
817879
});
818880

819881

@@ -848,7 +910,7 @@ describe('select', function() {
848910
expect(selectCtrl.hasOption('C')).toBe(true);
849911
expect(selectCtrl.hasOption('D')).toBe(false);
850912
expect(selectCtrl.hasOption('E')).toBe(false);
851-
expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C']});
913+
expect(element).toEqualSelectWithOptions({'': [[''], 'A'], 'first':['B', 'C']});
852914
});
853915
});
854916
});

0 commit comments

Comments
 (0)