From d43d943bca41dc45b029fd7050bab5c4f9bdb1b8 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sun, 4 Jan 2015 19:52:38 +0000 Subject: [PATCH 1/9] fix(ngOptions): ensure that the correct option is selected when options are loaded async **Major reworking of select and ngOptions**: * The `SelectController` is now used as an abstraction for the `select` and `ngOptions` directives to override to get their desired behaviour * The `select` directive is completely oblivious to the ngOptions directive now - the `ngOptions` directive could be deleted without having to make any changes to the `select` directive. * Select related directives (single/multiple/ngOptions) can provide specific versions of `SelectController.writeValue` and `SelectController.readValue`, which are responsible for getting the `$viewValue` in or out of the actual ` ``` Now it will be something like: ``` ``` If your application code relied on this value, which it shouldn't, then you will need to modify your application to accommodate this. You may find that you can use the `track by` feaure of `ngOptions` as this provides the ability to specify the key that is stored. BREAKING CHANGE: When iterating over an object's properties using the `(key, value) in obj` syntax the order of the elements used to be sorted alphabetically. This was an artificial attempt to create a deterministic ordering since browsers don't guarantee the order. But in practice this is not what people want and so this change iterates over properties in the order they are returned by Object.keys(obj), which is almost always the order in which the properties were defined. Closes #8019 Closes #9714 Closes #10639 --- src/ng/directive/select.js | 1110 ++++++----- test/ng/directive/selectSpec.js | 3059 ++++++++++++++++--------------- 2 files changed, 2218 insertions(+), 1951 deletions(-) diff --git a/src/ng/directive/select.js b/src/ng/directive/select.js index 071a90ab1022..8b7ce6995237 100644 --- a/src/ng/directive/select.js +++ b/src/ng/directive/select.js @@ -1,6 +1,116 @@ 'use strict'; +/* global jqLiteRemove */ + var ngOptionsMinErr = minErr('ngOptions'); + + +var noopNgModelController = { $setViewValue: noop, $render: noop }; + +/** + * @ngdoc type + * @name select.SelectController + * @description + * The controller for the ` and IE barfs otherwise. + self.unknownOption = jqLite(document.createElement('option')); + self.renderUnknownOption = function(val) { + var unknownVal = '? ' + hashKey(val) + ' ?'; + self.unknownOption.val(unknownVal); + $element.prepend(self.unknownOption); + $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; + }); + + self.removeUnknownOption = function() { + if (self.unknownOption.parent()) self.unknownOption.remove(); + }; + + // Here we find the option that represents the "empty" value, i.e. the option with a value + // of `""`. This option needs to be accessed (to select it directly) when setting the value + // of the select to `""` because IE9 will not automatically select the option. + // + // Additionally, the `ngOptions` directive uses this option to allow the application developer + // to provide their own custom "empty" option when the viewValue does not match any of the + // option values. + for (var i = 0, children = $element.children(), ii = children.length; i < ii; i++) { + if (children[i].value === '') { + self.emptyOption = children.eq(i); + break; + } + } + + // 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(); + }; + + + // Write the value to the select control, the implementation of this changes depending + // upon whether the select can have multiple values and whether ngOptions is at work. + self.writeValue = function writeSingleValue(value) { + if (self.hasOption(value)) { + self.removeUnknownOption(); + $element.val(value); + if (value === '') self.emptyOption.prop('selected', true); // to make IE9 happy + } else { + if (isUndefined(value) && self.emptyOption) { + $element.val(''); + } else { + self.renderUnknownOption(value); + } + } + }; + + + // Tell the select control that an option, with the given value, has been added + self.addOption = function(value) { + assertNotHasOwnProperty(value, '"option value"'); + var count = optionsMap.get(value) || 0; + optionsMap.put(value, count + 1); + }; + + // Tell the select control that an option, with the given value, has been removed + self.removeOption = function(value) { + var count = optionsMap.get(value); + if (count) { + if (count === 1) { + optionsMap.remove(value); + } else { + optionsMap.put(value, count - 1); + } + } + }; + + // Check whether the select control has an option matching the given value + self.hasOption = function(value) { + return !!optionsMap.get(value); + }; +}]; + /** * @ngdoc directive * @name select @@ -9,7 +119,184 @@ var ngOptionsMinErr = minErr('ngOptions'); * @description * HTML `SELECT` element with angular data-binding. * - * # `ngOptions` + * In many cases, `ngRepeat` can be used on `' + ''); - expect(element).toEqualSelect(['? undefined:undefined ?'], 'c3p0', 'r2d2'); + expect(element).toEqualSelect([unknownValue(undefined)], 'c3p0', 'r2d2'); scope.$apply(function() { scope.robot = 'r2d2'; @@ -344,7 +305,7 @@ describe('select', function() { scope.$apply(function() { scope.robot = "wallee"; }); - expect(element).toEqualSelect(['? string:wallee ?'], 'c3p0', 'r2d2'); + expect(element).toEqualSelect([unknownValue('wallee')], 'c3p0', 'r2d2'); }); @@ -362,7 +323,7 @@ describe('select', function() { scope.$apply(function() { scope.robot = null; }); - expect(element).toEqualSelect(['? object:null ?'], '', 'c3p0', 'r2d2'); + expect(element).toEqualSelect([unknownValue(null)], '', 'c3p0', 'r2d2'); scope.$apply(function() { scope.robot = 'r2d2'; @@ -385,7 +346,7 @@ describe('select', function() { '' + ''); - expect(element).toEqualSelect(['? string:wallee ?'], '', 'c3p0', 'r2d2'); + expect(element).toEqualSelect([unknownValue('wallee')], '', 'c3p0', 'r2d2'); scope.$apply(function() { scope.robot = 'r2d2'; @@ -400,13 +361,13 @@ describe('select', function() { compile(''); - expect(element).toEqualSelect(['? undefined:undefined ?']); + expect(element).toEqualSelect([unknownValue(undefined)]); expect(scope.robot).toBeUndefined(); scope.$apply(function() { scope.robot = 'r2d2'; }); - expect(element).toEqualSelect(['? string:r2d2 ?']); + expect(element).toEqualSelect([unknownValue('r2d2')]); expect(scope.robot).toBe('r2d2'); scope.$apply(function() { @@ -428,7 +389,7 @@ describe('select', function() { scope.$apply(function() { scope.robot = 'r2d2'; }); - expect(element).toEqualSelect(['? string:r2d2 ?'], ''); + expect(element).toEqualSelect([unknownValue('r2d2')], ''); expect(scope.robot).toBe('r2d2'); scope.$apply(function() { @@ -452,7 +413,7 @@ describe('select', function() { scope.$apply(function() { scope.robots.pop(); }); - expect(element).toEqualSelect(['? string:r2d2 ?'], 'c3p0'); + expect(element).toEqualSelect([unknownValue('r2d2')], 'c3p0'); expect(scope.robot).toBe('r2d2'); scope.$apply(function() { @@ -464,240 +425,333 @@ describe('select', function() { scope.$apply(function() { delete scope.robots; }); - expect(element).toEqualSelect(['? string:r2d2 ?']); + expect(element).toEqualSelect([unknownValue('r2d2')]); expect(scope.robot).toBe('r2d2'); }); + }); - describe('selectController.hasOption', function() { - it('should return false for options shifted via ngOptions', function() { - scope.robots = [ - {value: 1, label: 'c3p0'}, - {value: 2, label: 'r2d2'} - ]; + }); - compile(''); + }); - var selectCtrl = element.data().$selectController; - scope.$apply(function() { - scope.robots.shift(); - }); + describe('selectController.hasOption', function() { - expect(selectCtrl.hasOption('c3p0')).toBe(false); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); + function compileRepeatedOptions() { + compile(''); + } + + function compileGroupedOptions() { + compile( + ''); + } + describe('flat options', function() { + it('should return false for options shifted via ngRepeat', function() { + scope.robots = [ + {value: 1, label: 'c3p0'}, + {value: 2, label: 'r2d2'} + ]; - it('should return false for options popped via ngOptions', function() { - scope.robots = [ - {value: 1, label: 'c3p0'}, - {value: 2, label: 'r2d2'} - ]; + compileRepeatedOptions(); - compile(''); + var selectCtrl = element.controller('select'); - var selectCtrl = element.data().$selectController; + scope.$apply(function() { + scope.robots.shift(); + }); - scope.$apply(function() { - scope.robots.pop(); - }); + expect(selectCtrl.hasOption('1')).toBe(false); + expect(selectCtrl.hasOption('2')).toBe(true); + }); - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(false); - }); + it('should return false for options popped via ngRepeat', function() { + scope.robots = [ + {value: 1, label: 'c3p0'}, + {value: 2, label: 'r2d2'} + ]; - it('should return true for options added via ngOptions', function() { - scope.robots = [ - {value: 2, label: 'r2d2'} - ]; + compileRepeatedOptions(); - compile(''); + var selectCtrl = element.controller('select'); - var selectCtrl = element.data().$selectController; + scope.$apply(function() { + scope.robots.pop(); + }); - scope.$apply(function() { - scope.robots.unshift({value: 1, label: 'c3p0'}); - }); + expect(selectCtrl.hasOption('1')).toBe(true); + expect(selectCtrl.hasOption('2')).toBe(false); + }); - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); + it('should return true for options added via ngRepeat', function() { + scope.robots = [ + {value: 2, label: 'r2d2'} + ]; - it('should keep all the options when changing the model', function() { - compile(''); - var selectCtrl = element.controller('select'); - scope.$apply(function() { - scope.mySelect = 'C'; - }); - expect(selectCtrl.hasOption('A')).toBe(true); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(true); - expect(element).toEqualSelectWithOptions({'': ['A', 'B', 'C']}); - }); + compileRepeatedOptions(); + var selectCtrl = element.controller('select'); - it('should be able to detect when elements move from a previous group', function() { - scope.values = [ - {name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'first'}, - {name: 'D', group: 'first'}, - {name: 'E', group: 'second'} - ]; - - compile(''); - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.values[3] = {name: 'D', group: 'second'}; - scope.values.shift(); - }); - expect(selectCtrl.hasOption('A')).toBe(false); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(true); - expect(selectCtrl.hasOption('D')).toBe(true); - expect(selectCtrl.hasOption('E')).toBe(true); - expect(element).toEqualSelectWithOptions({'': [''], 'first':['B', 'C'], 'second': ['D', 'E']}); - }); + scope.$apply(function() { + scope.robots.unshift({value: 1, label: 'c3p0'}); + }); + expect(selectCtrl.hasOption('1')).toBe(true); + expect(selectCtrl.hasOption('2')).toBe(true); + }); - it('should be able to detect when elements move from a following group', function() { - scope.values = [ - {name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'first'}, - {name: 'D', group: 'second'}, - {name: 'E', group: 'second'} - ]; - - compile(''); - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.values[3].group = 'first'; - scope.values.shift(); - }); - expect(selectCtrl.hasOption('A')).toBe(false); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(true); - expect(selectCtrl.hasOption('D')).toBe(true); - expect(selectCtrl.hasOption('E')).toBe(true); - expect(element).toEqualSelectWithOptions({'': [''], 'first':['B', 'C', 'D'], 'second': ['E']}); - }); + it('should keep all the options when changing the model', function() { - it('should be able to detect when an element is replaced with an element from a previous group', function() { - scope.values = [ - {name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'first'}, - {name: 'D', group: 'first'}, - {name: 'E', group: 'second'}, - {name: 'F', group: 'second'} - ]; - - compile(''); - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.values[3].group = 'second'; - scope.values.pop(); - }); - expect(selectCtrl.hasOption('A')).toBe(true); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(true); - expect(selectCtrl.hasOption('D')).toBe(true); - expect(selectCtrl.hasOption('E')).toBe(true); - expect(selectCtrl.hasOption('F')).toBe(false); - expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C'], 'second': ['D', 'E']}); - }); + compile(''); + var selectCtrl = element.controller('select'); - it('should be able to detect when element is replaced with an element from a following group', function() { - scope.values = [ - {name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'first'}, - {name: 'D', group: 'second'}, - {name: 'E', group: 'second'} - ]; - - compile(''); - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.values[3].group = 'first'; - scope.values.splice(2, 1); - }); - expect(selectCtrl.hasOption('A')).toBe(true); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(false); - expect(selectCtrl.hasOption('D')).toBe(true); - expect(selectCtrl.hasOption('E')).toBe(true); - expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'D'], 'second': ['E']}); - }); + scope.$apply(function() { + scope.mySelect = 'C'; + }); + + expect(selectCtrl.hasOption('A')).toBe(true); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(true); + expect(element).toEqualSelectWithOptions({'': ['A', 'B', 'C']}); + }); + }); - it('should be able to detect when an element is removed', function() { - scope.values = [ - {name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'first'}, - {name: 'D', group: 'second'}, - {name: 'E', group: 'second'} - ]; - - compile(''); - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.values.splice(3, 1); - }); - expect(selectCtrl.hasOption('A')).toBe(true); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(true); - expect(selectCtrl.hasOption('D')).toBe(false); - expect(selectCtrl.hasOption('E')).toBe(true); - expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C'], 'second': ['E']}); - }); + describe('grouped options', function() { + + it('should be able to detect when elements move from a previous group', function() { + scope.values = [{name: 'A'}]; + scope.groups = [ + { + name: 'first', + values: [ + {name: 'B'}, + {name: 'C'}, + {name: 'D'} + ] + }, + { + name: 'second', + values: [ + {name: 'E'} + ] + } + ]; + compileGroupedOptions(); - it('should be able to detect when a group is removed', function() { - scope.values = [ - {name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'first'}, - {name: 'D', group: 'second'}, - {name: 'E', group: 'second'} - ]; - - compile(''); - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.values.splice(3, 2); - }); - expect(selectCtrl.hasOption('A')).toBe(true); - expect(selectCtrl.hasOption('B')).toBe(true); - expect(selectCtrl.hasOption('C')).toBe(true); - expect(selectCtrl.hasOption('D')).toBe(false); - expect(selectCtrl.hasOption('E')).toBe(false); - expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C']}); - }); + var selectCtrl = element.controller('select'); + + scope.$apply(function() { + var itemD = scope.groups[0].values.pop(); + scope.groups[1].values.unshift(itemD); + scope.values.shift(); + }); + + expect(selectCtrl.hasOption('A')).toBe(false); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(true); + expect(selectCtrl.hasOption('D')).toBe(true); + expect(selectCtrl.hasOption('E')).toBe(true); + expect(element).toEqualSelectWithOptions({'': [''], 'first':['B', 'C'], 'second': ['D', 'E']}); + }); + + + it('should be able to detect when elements move from a following group', function() { + scope.values = [{name: 'A'}]; + scope.groups = [ + { + name: 'first', + values: [ + {name: 'B'}, + {name: 'C'} + ] + }, + { + name: 'second', + values: [ + {name: 'D'}, + {name: 'E'} + ] + } + ]; + + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); + + scope.$apply(function() { + var itemD = scope.groups[1].values.shift(); + scope.groups[0].values.push(itemD); + scope.values.shift(); + }); + expect(selectCtrl.hasOption('A')).toBe(false); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(true); + expect(selectCtrl.hasOption('D')).toBe(true); + expect(selectCtrl.hasOption('E')).toBe(true); + expect(element).toEqualSelectWithOptions({'': [''], 'first':['B', 'C', 'D'], 'second': ['E']}); + }); + + + it('should be able to detect when an element is replaced with an element from a previous group', function() { + scope.values = [{name: 'A'}]; + scope.groups = [ + { + name: 'first', + values: [ + {name: 'B'}, + {name: 'C'}, + {name: 'D'} + ] + }, + { + name: 'second', + values: [ + {name: 'E'}, + {name: 'F'} + ] + } + ]; + + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); + + scope.$apply(function() { + var itemD = scope.groups[0].values.pop(); + scope.groups[1].values.unshift(itemD); + scope.groups[1].values.pop(); + }); + expect(selectCtrl.hasOption('A')).toBe(true); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(true); + expect(selectCtrl.hasOption('D')).toBe(true); + expect(selectCtrl.hasOption('E')).toBe(true); + expect(selectCtrl.hasOption('F')).toBe(false); + expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C'], 'second': ['D', 'E']}); + }); + + + it('should be able to detect when element is replaced with an element from a following group', function() { + scope.values = [{name: 'A'}]; + scope.groups = [ + { + name: 'first', + values: [ + {name: 'B'}, + {name: 'C'} + ] + }, + { + name: 'second', + values: [ + {name: 'D'}, + {name: 'E'} + ] + } + ]; + + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); + + scope.$apply(function() { + scope.groups[0].values.pop(); + var itemD = scope.groups[1].values.shift(); + scope.groups[0].values.push(itemD); + }); + expect(selectCtrl.hasOption('A')).toBe(true); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(false); + expect(selectCtrl.hasOption('D')).toBe(true); + expect(selectCtrl.hasOption('E')).toBe(true); + expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'D'], 'second': ['E']}); + }); + + + it('should be able to detect when an element is removed', function() { + scope.values = [{name: 'A'}]; + scope.groups = [ + { + name: 'first', + values: [ + {name: 'B'}, + {name: 'C'} + ] + }, + { + name: 'second', + values: [ + {name: 'D'}, + {name: 'E'} + ] + } + ]; + + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); + + scope.$apply(function() { + scope.groups[1].values.shift(); + }); + expect(selectCtrl.hasOption('A')).toBe(true); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(true); + expect(selectCtrl.hasOption('D')).toBe(false); + expect(selectCtrl.hasOption('E')).toBe(true); + expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C'], 'second': ['E']}); + }); + + + it('should be able to detect when a group is removed', function() { + scope.values = [{name: 'A'}]; + scope.groups = [ + { + name: 'first', + values: [ + {name: 'B'}, + {name: 'C'} + ] + }, + { + name: 'second', + values: [ + {name: 'D'}, + {name: 'E'} + ] + } + ]; + + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); + + scope.$apply(function() { + scope.groups.pop(); }); + expect(selectCtrl.hasOption('A')).toBe(true); + expect(selectCtrl.hasOption('B')).toBe(true); + expect(selectCtrl.hasOption('C')).toBe(true); + expect(selectCtrl.hasOption('D')).toBe(false); + expect(selectCtrl.hasOption('E')).toBe(false); + expect(element).toEqualSelectWithOptions({'': ['', 'A'], 'first':['B', 'C']}); }); }); }); - describe('select-multiple', function() { it('should support type="select-multiple"', function() { @@ -770,1648 +824,1757 @@ describe('select', function() { expect(element).toBeDirty(); }); - describe('selectController.hasOption', function() { - it('should return false for options shifted via ngOptions', function() { - scope.robots = [ - {value: 1, label: 'c3p0'}, - {value: 2, label: 'r2d2'} - ]; + }); - compile(''); - var selectCtrl = element.data().$selectController; + describe('ngOptions', function() { - scope.$apply(function() { - scope.robots.shift(); - }); + var scope, formElement, element, $compile; - expect(selectCtrl.hasOption('c3p0')).toBe(false); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); + function compile(html) { + formElement = jqLite('
' + html + '
'); + element = formElement.find('select'); + $compile(formElement)(scope); + scope.$apply(); + } - it('should return false for options popped via ngOptions', function() { - scope.robots = [ - {value: 1, label: 'c3p0'}, - {value: 2, label: 'r2d2'} - ]; + function setSelectValue(selectElement, optionIndex) { + var option = selectElement.find('option').eq(optionIndex); + selectElement.val(option.val()); + browserTrigger(element, 'change'); + } - compile(''); - var selectCtrl = element.data().$selectController; + beforeEach(function() { + this.addMatchers({ + toEqualSelectValue: function(value, multiple) { + var errors = []; - scope.$apply(function() { - scope.robots.pop(); - }); + if (multiple) { + value = value.map(function(val) { return hashKey(val); }); + } else { + value = hashKey(value); + } - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(false); - }); + if (!equals(this.actual.val(), value)) { + errors.push('Expected select value "' + this.actual.val() + '" to equal "' + value + '"'); + } + this.message = function() { + return errors.join('\n'); + }; - it('should return true for options added via ngOptions', function() { - scope.robots = [ - {value: 2, label: 'r2d2'} - ]; + return errors.length === 0; + }, + toEqualOption: function(value, text, label) { + var errors = []; + var hash = hashKey(value); + if (this.actual.attr('value') !== hash) { + errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "' + hash + '"'); + } + if (text && this.actual.text() !== text) { + errors.push('Expected option text "' + this.actual.text() + '" to equal "' + text + '"'); + } + if (label && this.actual.attr('label') !== label) { + errors.push('Expected option label "' + this.actual.attr('label') + '" to equal "' + label + '"'); + } - compile(''); + this.message = function() { + return errors.join('\n'); + }; - var selectCtrl = element.data().$selectController; + return errors.length === 0; + }, + toEqualTrackedOption: function(value, text, label) { + var errors = []; + if (this.actual.attr('value') !== '' + value) { + errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "' + value + '"'); + } + if (text && this.actual.text() !== text) { + errors.push('Expected option text "' + this.actual.text() + '" to equal "' + text + '"'); + } + if (label && this.actual.attr('label') !== label) { + errors.push('Expected option label "' + this.actual.attr('label') + '" to equal "' + label + '"'); + } - scope.$apply(function() { - scope.robots.unshift({value: 1, label: 'c3p0'}); - }); + this.message = function() { + return errors.join('\n'); + }; - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); - }); - }); + return errors.length === 0; + }, + toEqualUnknownOption: function() { + var errors = []; + if (this.actual.attr('value') !== '?') { + errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "?"'); + } + this.message = function() { + return errors.join('\n'); + }; - describe('ngOptions', function() { - function createSelect(attrs, blank, unknown) { - var html = 'blank') : '') + - (unknown ? (isString(unknown) ? unknown : '') : '') + - ''; - compile(html); - } + this.message = function() { + return errors.join('\n'); + }; - function createSingleSelect(blank, unknown) { - createSelect({ - 'ng-model':'selected', - 'ng-options':'value.name for value in values' - }, blank, unknown); - } + return errors.length === 0; + } + }); + }); - function createMultiSelect(blank, unknown) { - createSelect({ - 'ng-model':'selected', - 'multiple':true, - 'ng-options':'value.name for value in values' - }, blank, unknown); - } + beforeEach(inject(function($rootScope, _$compile_) { + scope = $rootScope.$new(); //create a child scope because the root scope can't be $destroy-ed + $compile = _$compile_; + formElement = element = null; + })); - describe('selectAs expression', function() { - beforeEach(function() { - scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]; - scope.obj = {'10': {score: 10, label: 'ten'}, '20': {score: 20, label: 'twenty'}}; - }); - it('should support single select with array source', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.label for item in arr' - }); + afterEach(function() { + scope.$destroy(); //disables unknown option work during destruction + dealoc(formElement); + }); - scope.$apply(function() { - scope.selected = 10; - }); - expect(element.val()).toBe('0'); + function createSelect(attrs, blank, unknown) { + var html = 'blank') : '') + + (unknown ? (isString(unknown) ? unknown : '') : '') + + ''; - element.val('1'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(20); - }); + compile(html); + } + function createSingleSelect(blank, unknown) { + createSelect({ + 'ng-model':'selected', + 'ng-options':'value.name for value in values' + }, blank, unknown); + } - it('should support multi select with array source', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'item.id as item.label for item in arr' - }); + function createMultiSelect(blank, unknown) { + createSelect({ + 'ng-model':'selected', + 'multiple':true, + 'ng-options':'value.name for value in values' + }, blank, unknown); + } - scope.$apply(function() { - scope.selected = [10,20]; - }); - expect(element.val()).toEqual(['0','1']); - expect(scope.selected).toEqual([10,20]); - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([20]); - expect(element.val()).toEqual(['1']); - }); + describe('selectAs expression', function() { + beforeEach(function() { + scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]; + scope.obj = {'10': {score: 10, label: 'ten'}, '20': {score: 20, label: 'twenty'}}; + }); + it('should support single select with array source', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.id as item.label for item in arr' + }); - it('should support single select with object source', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.score as val.label for (key, val) in obj' - }); + scope.$apply(function() { + scope.selected = 10; + }); + expect(element).toEqualSelectValue(10); - scope.$apply(function() { - scope.selected = 10; - }); - expect(element.val()).toBe('10'); + setSelectValue(element, 1); + expect(scope.selected).toBe(20); + }); - element.val('20'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(20); + + it('should support multi select with array source', function() { + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'item.id as item.label for item in arr' }); + scope.$apply(function() { + scope.selected = [10,20]; + }); + expect(element).toEqualSelectValue([10,20], true); + expect(scope.selected).toEqual([10,20]); - it('should support multi select with object source', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'val.score as val.label for (key, val) in obj' - }); + element.children()[0].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([20]); + expect(element).toEqualSelectValue([20], true); + }); - scope.$apply(function() { - scope.selected = [10,20]; - }); - expect(element.val()).toEqual(['10','20']); - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([20]); - expect(element.val()).toEqual(['20']); + it('should support single select with object source', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'val.score as val.label for (key, val) in obj' }); + + scope.$apply(function() { + scope.selected = 10; + }); + expect(element).toEqualSelectValue(10); + + setSelectValue(element, 1); + expect(scope.selected).toBe(20); }); - describe('trackBy expression', function() { - beforeEach(function() { - scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]; - scope.obj = {'1': {score: 10, label: 'ten'}, '2': {score: 20, label: 'twenty'}}; + it('should support multi select with object source', function() { + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'val.score as val.label for (key, val) in obj' }); + scope.$apply(function() { + scope.selected = [10,20]; + }); + expect(element).toEqualSelectValue([10,20], true); - it('should set the result of track by expression to element value', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.label for item in arr track by item.id' - }); + element.children()[0].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([20]); + expect(element).toEqualSelectValue([20], true); + }); + }); - scope.$apply(function() { - scope.selected = scope.arr[0]; - }); - expect(element.val()).toBe('10'); - scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; - }); - expect(element.val()).toBe('10'); + describe('trackBy expression', function() { + beforeEach(function() { + scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]; + scope.obj = {'1': {score: 10, label: 'ten'}, '2': {score: 20, label: 'twenty'}}; + }); - element.children()[1].selected = 'selected'; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.arr[1]); + + it('should set the result of track by expression to element value', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.label for item in arr track by item.id' }); + expect(element.val()).toEqualUnknownValue(); - it('should use the tracked expression as option value', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.label for item in arr track by item.id' - }); + scope.$apply(function() { + scope.selected = scope.arr[0]; + }); + expect(element.val()).toBe('10'); - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption('?', ''); - expect(options.eq(1)).toEqualOption('10', 'ten'); - expect(options.eq(2)).toEqualOption('20', 'twenty'); + scope.$apply(function() { + scope.arr[0] = {id: 10, label: 'new ten'}; }); + expect(element.val()).toBe('10'); - it('should preserve value even when reference has changed (single&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.label for item in arr track by item.id' - }); + element.children()[1].selected = 'selected'; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(scope.arr[1]); + }); - scope.$apply(function() { - scope.selected = scope.arr[0]; - }); - expect(element.val()).toBe('10'); - scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; - }); - expect(element.val()).toBe('10'); + it('should use the tracked expression as option value', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.label for item in arr track by item.id' + }); - element.children()[1].selected = 1; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.arr[1]); + var options = element.find('option'); + expect(options.length).toEqual(3); + expect(options.eq(0)).toEqualUnknownOption(); + expect(options.eq(1)).toEqualTrackedOption(10, 'ten'); + expect(options.eq(2)).toEqualTrackedOption(20, 'twenty'); + }); + + it('should preserve value even when reference has changed (single&array)', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.label for item in arr track by item.id' }); + scope.$apply(function() { + scope.selected = scope.arr[0]; + }); + expect(element.val()).toBe('10'); - it('should preserve value even when reference has changed (multi&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'item.label for item in arr track by item.id' - }); + scope.$apply(function() { + scope.arr[0] = {id: 10, label: 'new ten'}; + }); + expect(element.val()).toBe('10'); - scope.$apply(function() { - scope.selected = scope.arr; - }); - expect(element.val()).toEqual(['10','20']); + element.children()[1].selected = 1; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(scope.arr[1]); + }); - scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; - }); - expect(element.val()).toEqual(['10','20']); - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.arr[1]]); + it('should preserve value even when reference has changed (multi&array)', function() { + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'item.label for item in arr track by item.id' }); + scope.$apply(function() { + scope.selected = scope.arr; + }); + expect(element.val()).toEqual(['10','20']); - it('should preserve value even when reference has changed (single&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.label for (key, val) in obj track by val.score' - }); + scope.$apply(function() { + scope.arr[0] = {id: 10, label: 'new ten'}; + }); + expect(element.val()).toEqual(['10','20']); - scope.$apply(function() { - scope.selected = scope.obj['1']; - }); - expect(element.val()).toBe('10'); + element.children()[0].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([scope.arr[1]]); + }); - scope.$apply(function() { - scope.obj['1'] = {score: 10, label: 'ten'}; - }); - expect(element.val()).toBe('10'); - element.val('20'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(scope.obj['2']); + it('should preserve value even when reference has changed (single&object)', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'val.label for (key, val) in obj track by val.score' }); + scope.$apply(function() { + scope.selected = scope.obj['1']; + }); + expect(element.val()).toBe('10'); - it('should preserve value even when reference has changed (multi&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'val.label for (key, val) in obj track by val.score' - }); + scope.$apply(function() { + scope.obj['1'] = {score: 10, label: 'ten'}; + }); + expect(element.val()).toBe('10'); - scope.$apply(function() { - scope.selected = [scope.obj['1']]; - }); - expect(element.val()).toEqual(['10']); + setSelectValue(element, 1); + expect(scope.selected).toBe(scope.obj['2']); + }); - scope.$apply(function() { - scope.obj['1'] = {score: 10, label: 'ten'}; - }); - expect(element.val()).toEqual(['10']); - element.children()[1].selected = 'selected'; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.obj['1'], scope.obj['2']]); + it('should preserve value even when reference has changed (multi&object)', function() { + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'val.label for (key, val) in obj track by val.score' }); - }); + scope.$apply(function() { + scope.selected = [scope.obj['1']]; + }); + expect(element.val()).toEqual(['10']); - /** - * This behavior is broken and should probably be cleaned up later as track by and select as - * aren't compatible. - */ - describe('selectAs+trackBy expression', function() { - beforeEach(function() { - scope.arr = [{subItem: {label: 'ten', id: 10}}, {subItem: {label: 'twenty', id: 20}}]; - scope.obj = {'10': {subItem: {id: 10, label: 'ten'}}, '20': {subItem: {id: 20, label: 'twenty'}}}; + scope.$apply(function() { + scope.obj['1'] = {score: 10, label: 'ten'}; }); + expect(element.val()).toEqual(['10']); + element.children()[1].selected = 'selected'; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([scope.obj['1'], scope.obj['2']]); + }); + }); - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (single&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.subItem as item.subItem.label for item in arr track by (item.id || item.subItem.id)' - }); - // First test model -> view + /** + * This behavior is broken and should probably be cleaned up later as track by and select as + * aren't compatible. + */ + describe('selectAs+trackBy expression', function() { + beforeEach(function() { + scope.arr = [{subItem: {label: 'ten', id: 10}}, {subItem: {label: 'twenty', id: 20}}]; + scope.obj = {'10': {subItem: {id: 10, label: 'ten'}}, '20': {subItem: {id: 20, label: 'twenty'}}}; + }); - scope.$apply(function() { - scope.selected = scope.arr[0].subItem; - }); - expect(element.val()).toEqual('10'); - scope.$apply(function() { - scope.selected = scope.arr[1].subItem; - }); - expect(element.val()).toEqual('20'); + it('It should use the "value" variable to represent items in the array as well as for the ' + + 'selected values in track by expression (single&array)', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.subItem as item.subItem.label for item in arr track by (item.id || item.subItem.id)' + }); - // Now test view -> model + // First test model -> view - element.val('10'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(scope.arr[0].subItem); + scope.$apply(function() { + scope.selected = scope.arr[0].subItem; + }); + expect(element.val()).toEqual('10'); - // Now reload the array - scope.$apply(function() { - scope.arr = [{ - subItem: {label: 'new ten', id: 10} - },{ - subItem: {label: 'new twenty', id: 20} - }]; - }); - expect(element.val()).toBe('10'); - expect(scope.selected.id).toBe(10); + scope.$apply(function() { + scope.selected = scope.arr[1].subItem; }); + expect(element.val()).toEqual('20'); + // Now test view -> model - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (multiple&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'item.subItem as item.subItem.label for item in arr track by (item.id || item.subItem.id)' - }); + element.val('10'); + browserTrigger(element, 'change'); + expect(scope.selected).toBe(scope.arr[0].subItem); - // First test model -> view + // Now reload the array + scope.$apply(function() { + scope.arr = [{ + subItem: {label: 'new ten', id: 10} + },{ + subItem: {label: 'new twenty', id: 20} + }]; + }); + expect(element.val()).toBe('10'); + expect(scope.selected.id).toBe(10); + }); - scope.$apply(function() { - scope.selected = [scope.arr[0].subItem]; - }); - expect(element.val()).toEqual(['10']); - scope.$apply(function() { - scope.selected = [scope.arr[1].subItem]; - }); - expect(element.val()).toEqual(['20']); + it('It should use the "value" variable to represent items in the array as well as for the ' + + 'selected values in track by expression (multiple&array)', function() { + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'item.subItem as item.subItem.label for item in arr track by (item.id || item.subItem.id)' + }); - // Now test view -> model + // First test model -> view - element.find('option')[0].selected = true; - element.find('option')[1].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.arr[0].subItem]); + scope.$apply(function() { + scope.selected = [scope.arr[0].subItem]; + }); + expect(element.val()).toEqual(['10']); - // Now reload the array - scope.$apply(function() { - scope.arr = [{ - subItem: {label: 'new ten', id: 10} - },{ - subItem: {label: 'new twenty', id: 20} - }]; - }); - expect(element.val()).toEqual(['10']); - expect(scope.selected[0].id).toEqual(10); - expect(scope.selected.length).toBe(1); + scope.$apply(function() { + scope.selected = [scope.arr[1].subItem]; }); + expect(element.val()).toEqual(['20']); + // Now test view -> model - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (multiple&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'val.subItem as val.subItem.label for (key, val) in obj track by (val.id || val.subItem.id)' - }); + element.find('option')[0].selected = true; + element.find('option')[1].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([scope.arr[0].subItem]); - // First test model -> view + // Now reload the array + scope.$apply(function() { + scope.arr = [{ + subItem: {label: 'new ten', id: 10} + },{ + subItem: {label: 'new twenty', id: 20} + }]; + }); + expect(element.val()).toEqual(['10']); + expect(scope.selected[0].id).toEqual(10); + expect(scope.selected.length).toBe(1); + }); - scope.$apply(function() { - scope.selected = [scope.obj['10'].subItem]; - }); - expect(element.val()).toEqual(['10']); + it('It should use the "value" variable to represent items in the array as well as for the ' + + 'selected values in track by expression (multiple&object)', function() { + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'val.subItem as val.subItem.label for (key, val) in obj track by (val.id || val.subItem.id)' + }); - scope.$apply(function() { - scope.selected = [scope.obj['10'].subItem]; - }); - expect(element.val()).toEqual(['10']); + // First test model -> view - // Now test view -> model + scope.$apply(function() { + scope.selected = [scope.obj['10'].subItem]; + }); + expect(element.val()).toEqual(['10']); - element.find('option')[0].selected = true; - element.find('option')[1].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.obj['10'].subItem]); - // Now reload the object - scope.$apply(function() { - scope.obj = { - '10': { - subItem: {label: 'new ten', id: 10} - }, - '20': { - subItem: {label: 'new twenty', id: 20} - } - }; - }); - expect(element.val()).toEqual(['10']); - expect(scope.selected[0].id).toBe(10); - expect(scope.selected.length).toBe(1); + scope.$apply(function() { + scope.selected = [scope.obj['10'].subItem]; }); + expect(element.val()).toEqual(['10']); + // Now test view -> model - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (single&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.subItem as val.subItem.label for (key, val) in obj track by (val.id || val.subItem.id)' - }); + element.find('option')[0].selected = true; + element.find('option')[1].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([scope.obj['10'].subItem]); - // First test model -> view + // Now reload the object + scope.$apply(function() { + scope.obj = { + '10': { + subItem: {label: 'new ten', id: 10} + }, + '20': { + subItem: {label: 'new twenty', id: 20} + } + }; + }); + expect(element.val()).toEqual(['10']); + expect(scope.selected[0].id).toBe(10); + expect(scope.selected.length).toBe(1); + }); - scope.$apply(function() { - scope.selected = scope.obj['10'].subItem; - }); - expect(element.val()).toEqual('10'); + it('It should use the "value" variable to represent items in the array as well as for the ' + + 'selected values in track by expression (single&object)', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'val.subItem as val.subItem.label for (key, val) in obj track by (val.id || val.subItem.id)' + }); - scope.$apply(function() { - scope.selected = scope.obj['10'].subItem; - }); - expect(element.val()).toEqual('10'); + // First test model -> view - // Now test view -> model + scope.$apply(function() { + scope.selected = scope.obj['10'].subItem; + }); + expect(element.val()).toEqual('10'); - element.find('option')[0].selected = true; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.obj['10'].subItem); - // Now reload the object - scope.$apply(function() { - scope.obj = { - '10': { - subItem: {label: 'new ten', id: 10} - }, - '20': { - subItem: {label: 'new twenty', id: 20} - } - }; - }); - expect(element.val()).toEqual('10'); - expect(scope.selected.id).toBe(10); + scope.$apply(function() { + scope.selected = scope.obj['10'].subItem; + }); + expect(element.val()).toEqual('10'); + + // Now test view -> model + + element.find('option')[0].selected = true; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(scope.obj['10'].subItem); + + // Now reload the object + scope.$apply(function() { + scope.obj = { + '10': { + subItem: {label: 'new ten', id: 10} + }, + '20': { + subItem: {label: 'new twenty', id: 20} + } + }; }); + expect(element.val()).toEqual('10'); + expect(scope.selected.id).toBe(10); }); + }); - it('should throw when not formated "? for ? in ?"', function() { - expect(function() { - compile(''); - }).toThrowMinErr('ngOptions', 'iexp', /Expected expression in form of/); + it('should throw when not formated "? for ? in ?"', function() { + expect(function() { + compile('')(scope); + }).toThrowMinErr('ngOptions', 'iexp', /Expected expression in form of/); + }); + + + it('should render a list', function() { + createSingleSelect(); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[1]; }); + var options = element.find('option'); + expect(options.length).toEqual(3); + expect(options.eq(0)).toEqualOption(scope.values[0], 'A'); + expect(options.eq(1)).toEqualOption(scope.values[1], 'B'); + expect(options.eq(2)).toEqualOption(scope.values[2], 'C'); + expect(options[1].selected).toEqual(true); + }); - it('should render a list', function() { - createSingleSelect(); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); + it('should render zero as a valid display value', function() { + createSingleSelect(); - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption('0', 'A'); - expect(options.eq(1)).toEqualOption('1', 'B'); - expect(options.eq(2)).toEqualOption('2', 'C'); + scope.$apply(function() { + scope.values = [{name: 0}, {name: 1}, {name: 2}]; + scope.selected = scope.values[0]; }); - it('should render zero as a valid display value', function() { - createSingleSelect(); + var options = element.find('option'); + expect(options.length).toEqual(3); + expect(options.eq(0)).toEqualOption(scope.values[0], '0'); + expect(options.eq(1)).toEqualOption(scope.values[1], '1'); + expect(options.eq(2)).toEqualOption(scope.values[2], '2'); + }); - scope.$apply(function() { - scope.values = [{name: 0}, {name: 1}, {name: 2}]; - scope.selected = scope.values[0]; - }); - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption('0', '0'); - expect(options.eq(1)).toEqualOption('1', '1'); - expect(options.eq(2)).toEqualOption('2', '2'); + it('should render an object', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'value as key for (key, value) in object' }); + scope.$apply(function() { + scope.object = {'red': 'FF0000', 'green': '00FF00', 'blue': '0000FF'}; + scope.selected = scope.object.green; + }); - it('should render an object', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'value as key for (key, value) in object' - }); + var options = element.find('option'); + expect(options.length).toEqual(3); + expect(options.eq(0)).toEqualOption('FF0000', 'red'); + expect(options.eq(1)).toEqualOption('00FF00', 'green'); + expect(options.eq(2)).toEqualOption('0000FF', 'blue'); + expect(options[1].selected).toEqual(true); scope.$apply(function() { - scope.object = {'red': 'FF0000', 'green': '00FF00', 'blue': '0000FF'}; - scope.selected = scope.object.red; + scope.object.azur = '8888FF'; }); - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption('blue', 'blue'); - expect(options.eq(1)).toEqualOption('green', 'green'); - expect(options.eq(2)).toEqualOption('red', 'red'); - expect(options[2].selected).toEqual(true); + options = element.find('option'); + expect(options[1].selected).toEqual(true); scope.$apply(function() { - scope.object.azur = '8888FF'; + scope.selected = scope.object.azur; }); - options = element.find('option'); - expect(options[3].selected).toEqual(true); - }); + options = element.find('option'); + expect(options[3].selected).toEqual(true); + }); - it('should grow list', function() { - createSingleSelect(); - scope.$apply(function() { - scope.values = []; - }); + it('should grow list', function() { + createSingleSelect(); - expect(element.find('option').length).toEqual(1); // because we add special empty option - expect(element.find('option')).toEqualOption('?',''); + scope.$apply(function() { + scope.values = []; + }); - scope.$apply(function() { - scope.values.push({name:'A'}); - scope.selected = scope.values[0]; - }); + expect(element.find('option').length).toEqual(1); // because we add special unknown option + expect(element.find('option').eq(0)).toEqualUnknownOption(); - expect(element.find('option').length).toEqual(1); - expect(element.find('option')).toEqualOption('0', 'A'); + scope.$apply(function() { + scope.values.push({name:'A'}); + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values.push({name:'B'}); - }); + expect(element.find('option').length).toEqual(1); + expect(element.find('option')).toEqualOption(scope.values[0], 'A'); - expect(element.find('option').length).toEqual(2); - expect(element.find('option').eq(0)).toEqualOption('0', 'A'); - expect(element.find('option').eq(1)).toEqualOption('1', 'B'); + scope.$apply(function() { + scope.values.push({name:'B'}); }); + expect(element.find('option').length).toEqual(2); + expect(element.find('option').eq(0)).toEqualOption(scope.values[0], 'A'); + expect(element.find('option').eq(1)).toEqualOption(scope.values[1], 'B'); + }); - it('should shrink list', function() { - createSingleSelect(); - scope.$apply(function() { - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - }); + it('should shrink list', function() { + createSingleSelect(); - expect(element.find('option').length).toEqual(3); + scope.$apply(function() { + scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values.pop(); - }); + expect(element.find('option').length).toEqual(3); - expect(element.find('option').length).toEqual(2); - expect(element.find('option').eq(0)).toEqualOption('0', 'A'); - expect(element.find('option').eq(1)).toEqualOption('1', 'B'); + scope.$apply(function() { + scope.values.pop(); + }); - scope.$apply(function() { - scope.values.pop(); - }); + expect(element.find('option').length).toEqual(2); + expect(element.find('option').eq(0)).toEqualOption(scope.values[0], 'A'); + expect(element.find('option').eq(1)).toEqualOption(scope.values[1], 'B'); - expect(element.find('option').length).toEqual(1); - expect(element.find('option')).toEqualOption('0', 'A'); + scope.$apply(function() { + scope.values.pop(); + }); - scope.$apply(function() { - scope.values.pop(); - scope.selected = null; - }); + expect(element.find('option').length).toEqual(1); + expect(element.find('option')).toEqualOption(scope.values[0], 'A'); - expect(element.find('option').length).toEqual(1); // we add back the special empty option + scope.$apply(function() { + scope.values.pop(); + scope.selected = null; }); + expect(element.find('option').length).toEqual(1); // we add back the special empty option + }); - it('should shrink and then grow list', function() { - createSingleSelect(); - scope.$apply(function() { - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - }); + it('should shrink and then grow list', function() { + createSingleSelect(); - expect(element.find('option').length).toEqual(3); + scope.$apply(function() { + scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values = [{name: '1'}, {name: '2'}]; - scope.selected = scope.values[0]; - }); + expect(element.find('option').length).toEqual(3); - expect(element.find('option').length).toEqual(2); + scope.$apply(function() { + scope.values = [{name: '1'}, {name: '2'}]; + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); + expect(element.find('option').length).toEqual(2); - expect(element.find('option').length).toEqual(3); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; }); + expect(element.find('option').length).toEqual(3); + }); - it('should update list', function() { - createSingleSelect(); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); + it('should update list', function() { + createSingleSelect(); - scope.$apply(function() { - scope.values = [{name: 'B'}, {name: 'C'}, {name: 'D'}]; - scope.selected = scope.values[0]; - }); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; + }); - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption('0', 'B'); - expect(options.eq(1)).toEqualOption('1', 'C'); - expect(options.eq(2)).toEqualOption('2', 'D'); + scope.$apply(function() { + scope.values = [{name: 'B'}, {name: 'C'}, {name: 'D'}]; + scope.selected = scope.values[0]; }); + var options = element.find('option'); + expect(options.length).toEqual(3); + expect(options.eq(0)).toEqualOption(scope.values[0], 'B'); + expect(options.eq(1)).toEqualOption(scope.values[1], 'C'); + expect(options.eq(2)).toEqualOption(scope.values[2], 'D'); + }); - it('should preserve existing options', function() { - createSingleSelect(true); + it('should preserve pre-existing empty option', function() { + createSingleSelect(true); - scope.$apply(function() { - scope.values = []; - }); + scope.$apply(function() { + scope.values = []; + }); + expect(element.find('option').length).toEqual(1); - expect(element.find('option').length).toEqual(1); + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = scope.values[0]; - }); + expect(element.find('option').length).toEqual(2); + expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); + expect(jqLite(element.find('option')[1]).text()).toEqual('A'); - expect(element.find('option').length).toEqual(2); - expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); - expect(jqLite(element.find('option')[1]).text()).toEqual('A'); + scope.$apply(function() { + scope.values = []; + scope.selected = null; + }); - scope.$apply(function() { - scope.values = []; - scope.selected = null; - }); + expect(element.find('option').length).toEqual(1); + expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); + }); - expect(element.find('option').length).toEqual(1); - expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); + + it('should ignore $ and $$ properties', function() { + createSelect({ + 'ng-options': 'key as value for (key, value) in object', + 'ng-model': 'selected' }); - it('should ignore $ and $$ properties', function() { - createSelect({ - 'ng-options': 'key as value for (key, value) in object', - 'ng-model': 'selected' - }); + scope.$apply(function() { + scope.object = {'regularProperty': 'visible', '$$private': 'invisible', '$property': 'invisible'}; + scope.selected = 'regularProperty'; + }); - scope.$apply(function() { - scope.object = {'regularProperty': 'visible', '$$private': 'invisible', '$property': 'invisible'}; - scope.selected = 'regularProperty'; - }); + var options = element.find('option'); + expect(options.length).toEqual(1); + expect(options.eq(0)).toEqualOption('regularProperty', 'visible'); + }); - var options = element.find('option'); - expect(options.length).toEqual(1); - expect(options.eq(0)).toEqualOption('regularProperty', 'visible'); + + it('should allow expressions over multiple lines', function() { + scope.isNotFoo = function(item) { + return item.name !== 'Foo'; + }; + + createSelect({ + 'ng-options': 'key.id\n' + + 'for key in values\n' + + '| filter:isNotFoo', + 'ng-model': 'selected' }); - it('should allow expressions over multiple lines', function() { - scope.isNotFoo = function(item) { - return item.name !== 'Foo'; - }; + scope.$apply(function() { + scope.values = [{'id': 1, 'name': 'Foo'}, + {'id': 2, 'name': 'Bar'}, + {'id': 3, 'name': 'Baz'}]; + scope.selected = scope.values[0]; + }); - createSelect({ - 'ng-options': 'key.id\n' + - 'for key in object\n' + - '| filter:isNotFoo', - 'ng-model': 'selected' - }); + var options = element.find('option'); + expect(options.length).toEqual(3); + expect(options.eq(1)).toEqualOption(scope.values[1], '2'); + expect(options.eq(2)).toEqualOption(scope.values[2], '3'); + }); - scope.$apply(function() { - scope.object = [{'id': 1, 'name': 'Foo'}, - {'id': 2, 'name': 'Bar'}, - {'id': 3, 'name': 'Baz'}]; - scope.selected = scope.object[0]; - }); - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(1)).toEqualOption('0', '2'); - expect(options.eq(2)).toEqualOption('1', '3'); + it('should not update selected property of an option element on digest with no change event', + function() { + // ng-options="value.name for value in values" + // ng-model="selected" + createSingleSelect(); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; }); - it('should not update selected property of an option element on digest with no change event', - function() { - // ng-options="value.name for value in values" - // ng-model="selected" - createSingleSelect(); + var options = element.find('option'); + + expect(scope.selected).toEqual(jasmine.objectContaining({ name: 'A' })); + expect(options.eq(0).prop('selected')).toBe(true); + expect(options.eq(1).prop('selected')).toBe(false); + + var optionToSelect = options.eq(1); + + expect(optionToSelect.text()).toBe('B'); + + optionToSelect.prop('selected', true); + scope.$digest(); + + expect(optionToSelect.prop('selected')).toBe(true); + expect(scope.selected).toBe(scope.values[0]); + }); + + + + it('should not be set when an option is selected and options are set asynchronously', + inject(function($timeout) { + compile(''); scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; + scope.model = 0; }); + $timeout(function() { + scope.options = [ + {id: 0, label: 'x'}, + {id: 1, label: 'y'} + ]; + }, 0); + + $timeout.flush(); + var options = element.find('option'); - expect(scope.selected).toEqual({ name: 'A' }); - expect(options.eq(0).prop('selected')).toBe(true); - expect(options.eq(1).prop('selected')).toBe(false); + expect(options.length).toEqual(2); + expect(options.eq(0)).toEqualOption(0, 'x'); + expect(options.eq(1)).toEqualOption(1, 'y'); + }) + ); - var optionToSelect = options.eq(1); - expect(optionToSelect.text()).toBe('B'); + // bug fix #9621 + it('should update the label property', function() { + // ng-options="value.name for value in values" + // ng-model="selected" + createSingleSelect(); - optionToSelect.prop('selected', true); - scope.$digest(); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; + }); - expect(optionToSelect.prop('selected')).toBe(true); - expect(scope.selected).toBe(scope.values[0]); + var options = element.find('option'); + expect(options.eq(0).prop('label')).toEqual('A'); + expect(options.eq(1).prop('label')).toEqual('B'); + expect(options.eq(2).prop('label')).toEqual('C'); + }); + + + // bug fix #9714 + it('should select the matching option when the options are updated', function() { + + // first set up a select with no options + scope.selected = ''; + createSelect({ + 'ng-options': 'val.id as val.label for val in values', + 'ng-model': 'selected' }); + var options = element.find('option'); + // we expect the selected option to be the "unknown" option + expect(options.eq(0)).toEqualUnknownOption(''); + expect(options.eq(0).prop('selected')).toEqual(true); + + // now add some real options - one of which matches the selected value + scope.$apply('values = [{id:"",label:"A"},{id:"1",label:"B"},{id:"2",label:"C"}]'); + + // we expect the selected option to be the one that matches the correct item + // and for the unknown option to have been removed + options = element.find('option'); + expect(element).toEqualSelectValue(''); + expect(options.eq(0)).toEqualOption('','A'); + }); + - // bug fix #9621 - it('should update the label property', function() { - // ng-options="value.name for value in values" - // ng-model="selected" + describe('binding', function() { + + it('should bind to scope value', function() { createSingleSelect(); scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.values = [{name: 'A'}, {name: 'B'}]; scope.selected = scope.values[0]; }); - var options = element.find('option'); - expect(options.eq(0).prop('label')).toEqual('A'); - expect(options.eq(1).prop('label')).toEqual('B'); - expect(options.eq(2).prop('label')).toEqual('C'); - }); + expect(element).toEqualSelectValue(scope.selected); - describe('binding', function() { + scope.$apply(function() { + scope.selected = scope.values[1]; + }); - it('should bind to scope value', function() { - createSingleSelect(); + expect(element).toEqualSelectValue(scope.selected); + }); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - }); - expect(element.val()).toEqual('0'); + it('should bind to scope value and group', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.name group by item.group for item in values' + }); - scope.$apply(function() { - scope.selected = scope.values[1]; - }); + scope.$apply(function() { + scope.values = [{name: 'A'}, + {name: 'B', group: 'first'}, + {name: 'C', group: 'second'}, + {name: 'D', group: 'first'}, + {name: 'E', group: 'second'}]; + scope.selected = scope.values[3]; + }); + + expect(element).toEqualSelectValue(scope.selected); + + var first = jqLite(element.find('optgroup')[0]); + var b = jqLite(first.find('option')[0]); + var d = jqLite(first.find('option')[1]); + expect(first.attr('label')).toEqual('first'); + expect(b.text()).toEqual('B'); + expect(d.text()).toEqual('D'); + + var second = jqLite(element.find('optgroup')[1]); + var c = jqLite(second.find('option')[0]); + var e = jqLite(second.find('option')[1]); + expect(second.attr('label')).toEqual('second'); + expect(c.text()).toEqual('C'); + expect(e.text()).toEqual('E'); - expect(element.val()).toEqual('1'); + scope.$apply(function() { + scope.selected = scope.values[0]; }); + expect(element).toEqualSelectValue(scope.selected); + }); - it('should bind to scope value and group', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.name group by item.group for item in values' - }); - - scope.$apply(function() { - scope.values = [{name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'second'}, - {name: 'D', group: 'first'}, - {name: 'E', group: 'second'}]; - scope.selected = scope.values[3]; - }); - expect(element.val()).toEqual('3'); + it('should bind to scope value and track/identify objects', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.name for item in values track by item.id' + }); - var first = jqLite(element.find('optgroup')[0]); - var b = jqLite(first.find('option')[0]); - var d = jqLite(first.find('option')[1]); - expect(first.attr('label')).toEqual('first'); - expect(b.text()).toEqual('B'); - expect(d.text()).toEqual('D'); + scope.$apply(function() { + scope.values = [{id: 1, name: 'first'}, + {id: 2, name: 'second'}, + {id: 3, name: 'third'}, + {id: 4, name: 'forth'}]; + scope.selected = scope.values[1]; + }); - var second = jqLite(element.find('optgroup')[1]); - var c = jqLite(second.find('option')[0]); - var e = jqLite(second.find('option')[1]); - expect(second.attr('label')).toEqual('second'); - expect(c.text()).toEqual('C'); - expect(e.text()).toEqual('E'); + expect(element.val()).toEqual('2'); - scope.$apply(function() { - scope.selected = scope.values[0]; - }); + 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'); - expect(element.val()).toEqual('0'); + scope.$apply(function() { + scope.selected = scope.values[3]; }); + expect(element.val()).toEqual('4'); + }); - it('should bind to scope value and track/identify objects', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': '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 = scope.values[1]; - }); + it('should bind to scope value through expression', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.id as item.name for item in values' + }); - expect(element.val()).toEqual('2'); + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + scope.selected = scope.values[0].id; + }); - 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'); + expect(element).toEqualSelectValue(scope.selected); - scope.$apply(function() { - scope.selected = scope.values[3]; - }); + scope.$apply(function() { + scope.selected = scope.values[1].id; + }); + + expect(element).toEqualSelectValue(scope.selected); + }); + + it('should update options in the DOM', function() { + compile( + '' + ); - expect(element.val()).toEqual('4'); + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + scope.selected = scope.values[0].id; }); + scope.$apply(function() { + scope.values[0].name = 'C'; + }); - it('should bind to scope value through experession', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.name for item in values' - }); + var options = element.find('option'); + expect(options.length).toEqual(2); + expect(options.eq(0)).toEqualOption(10, 'C'); + expect(options.eq(1)).toEqualOption(20, 'B'); + }); - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - scope.selected = scope.values[0].id; - }); - expect(element.val()).toEqual('0'); + it('should update options in the DOM from object source', function() { + compile( + '' + ); - scope.$apply(function() { - scope.selected = scope.values[1].id; - }); + scope.$apply(function() { + scope.values = {a: {id: 10, name: 'A'}, b: {id: 20, name: 'B'}}; + scope.selected = scope.values.a.id; + }); - expect(element.val()).toEqual('1'); + scope.$apply(function() { + scope.values.a.name = 'C'; }); - it('should update options in the DOM', function() { - compile( - '' - ); + var options = element.find('option'); + expect(options.length).toEqual(2); + expect(options.eq(0)).toEqualOption(10, 'C'); + expect(options.eq(1)).toEqualOption(20, 'B'); + }); - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - scope.selected = scope.values[0].id; - }); - scope.$apply(function() { - scope.values[0].name = 'C'; - }); + it('should bind to object key', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'key as value for (key, value) in object' + }); - var options = element.find('option'); - expect(options.length).toEqual(2); - expect(options.eq(0)).toEqualOption('0', 'C'); - expect(options.eq(1)).toEqualOption('1', 'B'); + scope.$apply(function() { + scope.object = {red: 'FF0000', green: '00FF00', blue: '0000FF'}; + scope.selected = 'green'; }); + expect(element).toEqualSelectValue(scope.selected); - it('should update options in the DOM from object source', function() { - compile( - '' - ); + scope.$apply(function() { + scope.selected = 'blue'; + }); - scope.$apply(function() { - scope.values = {a: {id: 10, name: 'A'}, b: {id: 20, name: 'B'}}; - scope.selected = scope.values.a.id; - }); + expect(element).toEqualSelectValue(scope.selected); + }); - scope.$apply(function() { - scope.values.a.name = 'C'; - }); - var options = element.find('option'); - expect(options.length).toEqual(2); - expect(options.eq(0)).toEqualOption('a', 'C'); - expect(options.eq(1)).toEqualOption('b', 'B'); + it('should bind to object value', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'value as key for (key, value) in object' }); + scope.$apply(function() { + scope.object = {red: 'FF0000', green: '00FF00', blue:'0000FF'}; + scope.selected = '00FF00'; + }); - it('should bind to object key', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'key as value for (key, value) in object' - }); + expect(element).toEqualSelectValue(scope.selected); - scope.$apply(function() { - scope.object = {red: 'FF0000', green: '00FF00', blue: '0000FF'}; - scope.selected = 'green'; - }); + scope.$apply(function() { + scope.selected = '0000FF'; + }); - expect(element.val()).toEqual('green'); + expect(element).toEqualSelectValue(scope.selected); + }); - scope.$apply(function() { - scope.selected = 'blue'; - }); - expect(element.val()).toEqual('blue'); + it('should insert a blank option if bound to null', function() { + createSingleSelect(); + + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = null; }); + expect(element.find('option').length).toEqual(2); + expect(element.val()).toEqual(''); + expect(jqLite(element.find('option')[0]).val()).toEqual(''); - it('should bind to object value', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'value as key for (key, value) in object' - }); + scope.$apply(function() { + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.object = {red: 'FF0000', green: '00FF00', blue:'0000FF'}; - scope.selected = '00FF00'; - }); + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').length).toEqual(1); + }); - expect(element.val()).toEqual('green'); - scope.$apply(function() { - scope.selected = '0000FF'; - }); + it('should reuse blank option if bound to null', function() { + createSingleSelect(true); - expect(element.val()).toEqual('blue'); + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = null; }); + expect(element.find('option').length).toEqual(2); + expect(element.val()).toEqual(''); + expect(jqLite(element.find('option')[0]).val()).toEqual(''); - it('should insert a blank option if bound to null', function() { - createSingleSelect(); + scope.$apply(function() { + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = null; - }); + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').length).toEqual(2); + }); - expect(element.find('option').length).toEqual(2); - expect(element.val()).toEqual(''); - expect(jqLite(element.find('option')[0]).val()).toEqual(''); - scope.$apply(function() { - scope.selected = scope.values[0]; - }); + it('should insert a unknown option if bound to something not in the list', function() { + createSingleSelect(); - expect(element.val()).toEqual('0'); - expect(element.find('option').length).toEqual(1); + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = {}; }); + expect(element.find('option').length).toEqual(2); + expect(element.val()).toEqualUnknownValue(scope.selected); + expect(element.find('option').eq(0)).toEqualUnknownOption(scope.selected); - it('should reuse blank option if bound to null', function() { - createSingleSelect(true); + scope.$apply(function() { + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = null; - }); + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').length).toEqual(1); + }); - expect(element.find('option').length).toEqual(2); - expect(element.val()).toEqual(''); - expect(jqLite(element.find('option')[0]).val()).toEqual(''); - scope.$apply(function() { - scope.selected = scope.values[0]; - }); + it('should select correct input if previously selected option was "?"', function() { + createSingleSelect(); - expect(element.val()).toEqual('0'); - expect(element.find('option').length).toEqual(2); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = {}; }); + expect(element.find('option').length).toEqual(3); + expect(element.val()).toEqualUnknownValue(); + expect(element.find('option').eq(0)).toEqualUnknownOption(); - it('should insert a unknown option if bound to something not in the list', function() { - createSingleSelect(); + browserTrigger(element.find('option').eq(1)); + expect(element.find('option').length).toEqual(2); + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').eq(0).prop('selected')).toBeTruthy(); + }); - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = {}; - }); - expect(element.find('option').length).toEqual(2); - expect(element.val()).toEqual('?'); - expect(jqLite(element.find('option')[0]).val()).toEqual('?'); + it('should use exact same values as values in scope with one-time bindings', function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'value.name for value in ::values' + }); - scope.$apply(function() { - scope.selected = scope.values[0]; - }); + browserTrigger(element.find('option').eq(1)); - expect(element.val()).toEqual('0'); - expect(element.find('option').length).toEqual(1); - }); + expect(scope.selected).toBe(scope.values[1]); + }); - it('should select correct input if previously selected option was "?"', function() { - createSingleSelect(); + it('should ensure that at least one option element has the "selected" attribute', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.id as item.name for item in values' + }); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = {}; - }); + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + }); + expect(element.val()).toEqualUnknownValue(); + expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - expect(element.find('option').length).toEqual(3); - expect(element.val()).toEqual('?'); - expect(element.find('option').eq(0).val()).toEqual('?'); + scope.$apply(function() { + scope.selected = 10; + }); + // Here the ? option should disappear and the first real option should have selected attribute + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - browserTrigger(element.find('option').eq(1)); - expect(element.val()).toEqual('0'); - expect(element.find('option').eq(0).prop('selected')).toBeTruthy(); - expect(element.find('option').length).toEqual(2); + // Here the selected value is changed and we change the selected attribute + scope.$apply(function() { + scope.selected = 20; }); + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').eq(1).attr('selected')).toEqual('selected'); + scope.$apply(function() { + scope.values.push({id: 30, name: 'C'}); + }); + expect(element).toEqualSelectValue(scope.selected); + expect(element.find('option').eq(1).attr('selected')).toEqual('selected'); - it('should use exact same values as values in scope with one-time bindings', function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'value.name for value in ::values' - }); + // Here the ? option should reappear and have selected attribute + scope.$apply(function() { + scope.selected = undefined; + }); + expect(element.val()).toEqualUnknownValue(); + expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); + }); - browserTrigger(element.find('option').eq(1)); - expect(scope.selected).toBe(scope.values[1]); + it('should select the correct option for selectAs and falsy values', function() { + scope.values = [{value: 0, label: 'zero'}, {value: 1, label: 'one'}]; + scope.selected = ''; + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'option.value as option.label for option in values' }); + var option = element.find('option').eq(0); + expect(option).toEqualUnknownOption(); + }); + }); - it('should ensure that at least one option element has the "selected" attribute', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.name for item in values' - }); - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - }); - expect(element.val()).toEqual('?'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); + describe('blank option', function() { - scope.$apply(function() { - scope.selected = 10; - }); - // Here the ? option should disappear and the first real option should have selected attribute - expect(element.val()).toEqual('0'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); + it('should be compiled as template, be watched and updated', function() { + var option; + createSingleSelect(''); - // Here the selected value is changed but we don't change the selected attribute - scope.$apply(function() { - scope.selected = 20; - }); - expect(element.val()).toEqual('1'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); + scope.$apply(function() { + scope.blankVal = 'so blank'; + scope.values = [{name: 'A'}]; + }); - scope.$apply(function() { - scope.values.push({id: 30, name: 'C'}); - }); - expect(element.val()).toEqual('1'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('blank is so blank'); - // Here the ? option should reappear and have selected attribute - scope.$apply(function() { - scope.selected = undefined; - }); - expect(element.val()).toEqual('?'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); + scope.$apply(function() { + scope.blankVal = 'not so blank'; }); + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('blank is not so blank'); + }); + - it('should select the correct option for selectAs and falsy values', function() { - scope.values = [{value: 0, label: 'zero'}, {value: 1, label: 'one'}]; - scope.selected = ''; - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'option.value as option.label for option in values' - }); + it('should support binding via ngBindTemplate directive', function() { + var option; + createSingleSelect(''); - var option = element.find('option').eq(0); - expect(option.val()).toBe('?'); - expect(option.text()).toBe(''); + scope.$apply(function() { + scope.blankVal = 'so blank'; + scope.values = [{name: 'A'}]; }); + + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('blank is so blank'); }); - describe('blank option', function() { + it('should support biding via ngBind attribute', function() { + var option; + createSingleSelect(''); - it('should be compiled as template, be watched and updated', function() { - var option; - createSingleSelect(''); + scope.$apply(function() { + scope.blankVal = 'is blank'; + scope.values = [{name: 'A'}]; + }); - scope.$apply(function() { - scope.blankVal = 'so blank'; - scope.values = [{name: 'A'}]; - }); + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('is blank'); + }); - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('blank is so blank'); - scope.$apply(function() { - scope.blankVal = 'not so blank'; - }); + it('should be rendered with the attributes preserved', function() { + var option; + createSingleSelect(''); - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('blank is not so blank'); + scope.$apply(function() { + scope.blankVal = 'is blank'; }); + // check blank option is first and is compiled + option = element.find('option').eq(0); + expect(option.hasClass('coyote')).toBeTruthy(); + expect(option.attr('id')).toBe('road-runner'); + expect(option.attr('custom-attr')).toBe('custom-attr'); + }); - it('should support binding via ngBindTemplate directive', function() { - var option; - createSingleSelect(''); - - scope.$apply(function() { - scope.blankVal = 'so blank'; - scope.values = [{name: 'A'}]; - }); - - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('blank is so blank'); + it('should be selected, if it is available and no other option is selected', function() { + // selectedIndex is used here because jqLite incorrectly reports element.val() + scope.$apply(function() { + scope.values = [{name: 'A'}]; }); + createSingleSelect(true); + // ensure the first option (the blank option) is selected + expect(element[0].selectedIndex).toEqual(0); + scope.$digest(); + // ensure the option has not changed following the digest + expect(element[0].selectedIndex).toEqual(0); + }); + }); - it('should support biding via ngBind attribute', function() { - var option; - createSingleSelect(''); + describe('on change', function() { - scope.$apply(function() { - scope.blankVal = 'is blank'; - scope.values = [{name: 'A'}]; - }); + it('should update model on change', function() { + createSingleSelect(); - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('is blank'); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; }); + expect(element).toEqualSelectValue(scope.selected); - it('should be rendered with the attributes preserved', function() { - var option; - createSingleSelect(''); + setSelectValue(element, 1); + expect(scope.selected).toEqual(scope.values[1]); + }); - scope.$apply(function() { - scope.blankVal = 'is blank'; - }); - // check blank option is first and is compiled - option = element.find('option').eq(0); - expect(option.hasClass('coyote')).toBeTruthy(); - expect(option.attr('id')).toBe('road-runner'); - expect(option.attr('custom-attr')).toBe('custom-attr'); + it('should update model on change through expression', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.id as item.name for item in values' }); - it('should be selected, if it is available and no other option is selected', function() { - // selectedIndex is used here because jqLite incorrectly reports element.val() - scope.$apply(function() { - scope.values = [{name: 'A'}]; - }); - createSingleSelect(true); - // ensure the first option (the blank option) is selected - expect(element[0].selectedIndex).toEqual(0); - scope.$digest(); - // ensure the option has not changed following the digest - expect(element[0].selectedIndex).toEqual(0); + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + scope.selected = scope.values[0].id; }); + + expect(element).toEqualSelectValue(scope.selected); + + setSelectValue(element, 1); + expect(scope.selected).toEqual(scope.values[1].id); }); - describe('on change', function() { + it('should update model to null on change', function() { + createSingleSelect(true); - it('should update model on change', function() { - createSingleSelect(); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + }); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - }); + element.val(''); + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(null); + }); - expect(element.val()).toEqual('0'); - element.val('1'); - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.values[1]); + // Regression https://github.com/angular/angular.js/issues/7855 + it('should update the model with ng-change', function() { + createSelect({ + 'ng-change':'change()', + 'ng-model':'selected', + 'ng-options':'value for value in values' }); + scope.$apply(function() { + scope.values = ['A', 'B']; + scope.selected = 'A'; + }); - it('should update model on change through expression', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.name for item in values' - }); + scope.change = function() { + scope.selected = 'A'; + }; - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - scope.selected = scope.values[0].id; - }); + element.find('option')[1].selected = true; - expect(element.val()).toEqual('0'); + browserTrigger(element, 'change'); + expect(element.find('option')[0].selected).toBeTruthy(); + expect(scope.selected).toEqual('A'); + }); + }); - element.val('1'); - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.values[1].id); + describe('disabled blank', function() { + it('should select disabled blank by default', function() { + var html = ''; + scope.$apply(function() { + scope.choices = ['A', 'B', 'C']; }); + compile(html); + + var options = element.find('option'); + var optionToSelect = options.eq(0); + expect(optionToSelect.text()).toBe('Choose One'); + expect(optionToSelect.prop('selected')).toBe(true); + expect(element[0].value).toBe(''); - it('should update model to null on change', function() { - createSingleSelect(true); + dealoc(element); + }); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - element.val('0'); - }); - element.val(''); - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(null); + it('should select disabled blank by default when select is required', function() { + var html = ''; + scope.$apply(function() { + scope.choices = ['A', 'B', 'C']; }); + compile(html); - // Regression https://github.com/angular/angular.js/issues/7855 - it('should update the model with ng-change', function() { - createSelect({ - 'ng-change':'change()', - 'ng-model':'selected', - 'ng-options':'value for value in values' - }); + var options = element.find('option'); + var optionToSelect = options.eq(0); + expect(optionToSelect.text()).toBe('Choose One'); + expect(optionToSelect.prop('selected')).toBe(true); + expect(element[0].value).toBe(''); - scope.$apply(function() { - scope.values = ['A', 'B']; - scope.selected = 'A'; - }); + dealoc(element); + }); + }); - scope.change = function() { - scope.selected = 'A'; - }; + describe('select-many', function() { - element.find('option')[1].selected = true; + it('should read multiple selection', function() { + createMultiSelect(); - browserTrigger(element, 'change'); - expect(element.find('option')[0].selected).toBeTruthy(); - expect(scope.selected).toEqual('A'); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = []; }); - }); - describe('disabled blank', function() { - it('should select disabled blank by default', function() { - var html = ''; - scope.$apply(function() { - scope.choices = ['A', 'B', 'C']; - }); + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeFalsy(); + expect(element.find('option')[1].selected).toBeFalsy(); - compile(html); + scope.$apply(function() { + scope.selected.push(scope.values[1]); + }); - var options = element.find('option'); - var optionToSelect = options.eq(0); - expect(optionToSelect.text()).toBe('Choose One'); - expect(optionToSelect.prop('selected')).toBe(true); - expect(element[0].value).toBe(''); + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeFalsy(); + expect(element.find('option')[1].selected).toBeTruthy(); - dealoc(element); + scope.$apply(function() { + scope.selected.push(scope.values[0]); }); + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeTruthy(); + expect(element.find('option')[1].selected).toBeTruthy(); + }); - it('should select disabled blank by default when select is required', function() { - var html = ''; - scope.$apply(function() { - scope.choices = ['A', 'B', 'C']; - }); - - compile(html); - var options = element.find('option'); - var optionToSelect = options.eq(0); - expect(optionToSelect.text()).toBe('Choose One'); - expect(optionToSelect.prop('selected')).toBe(true); - expect(element[0].value).toBe(''); + it('should update model on change', function() { + createMultiSelect(); - dealoc(element); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = []; }); - }); - describe('select-many', function() { + element.find('option')[0].selected = true; - it('should read multiple selection', function() { - createMultiSelect(); + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([scope.values[0]]); + }); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = []; - }); - expect(element.find('option').length).toEqual(2); - expect(element.find('option')[0].selected).toBeFalsy(); - expect(element.find('option')[1].selected).toBeFalsy(); + it('should select from object', function() { + createSelect({ + 'ng-model':'selected', + 'multiple':true, + 'ng-options':'key as value for (key,value) in values' + }); + scope.values = {'0':'A', '1':'B'}; - scope.$apply(function() { - scope.selected.push(scope.values[1]); - }); + scope.selected = ['1']; + scope.$digest(); + expect(element.find('option')[1].selected).toBe(true); - expect(element.find('option').length).toEqual(2); - expect(element.find('option')[0].selected).toBeFalsy(); - expect(element.find('option')[1].selected).toBeTruthy(); + element.find('option')[0].selected = true; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(['0', '1']); - scope.$apply(function() { - scope.selected.push(scope.values[0]); - }); + element.find('option')[1].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(['0']); + }); - expect(element.find('option').length).toEqual(2); - expect(element.find('option')[0].selected).toBeTruthy(); - expect(element.find('option')[1].selected).toBeTruthy(); + it('should deselect all options when model is emptied', function() { + createMultiSelect(); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = [scope.values[0]]; }); + expect(element.find('option')[0].selected).toEqual(true); + scope.$apply(function() { + scope.selected.pop(); + }); - it('should update model on change', function() { - createMultiSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = []; - }); + expect(element.find('option')[0].selected).toEqual(false); + }); + }); - element.find('option')[0].selected = true; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.values[0]]); - }); + describe('ngRequired', function() { + it('should allow bindings on ngRequired', function() { + createSelect({ + 'ng-model': 'value', + 'ng-options': 'item.name for item in values', + 'ng-required': 'required' + }, true); - it('should select from object', function() { - createSelect({ - 'ng-model':'selected', - 'multiple':true, - 'ng-options':'key as value for (key,value) in values' - }); - scope.values = {'0':'A', '1':'B'}; - scope.selected = ['1']; - scope.$digest(); - expect(element.find('option')[1].selected).toBe(true); + scope.$apply(function() { + scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; + scope.required = false; + }); - element.find('option')[0].selected = true; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(['0', '1']); + element.val(''); + browserTrigger(element, 'change'); + expect(element).toBeValid(); - element.find('option')[1].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(['0']); + scope.$apply(function() { + scope.required = true; }); + expect(element).toBeInvalid(); - it('should deselect all options when model is emptied', function() { - createMultiSelect(); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = [scope.values[0]]; - }); - expect(element.find('option')[0].selected).toEqual(true); + scope.$apply(function() { + scope.value = scope.values[0]; + }); + expect(element).toBeValid(); - scope.$apply(function() { - scope.selected.pop(); - }); + element.val(''); + browserTrigger(element, 'change'); + expect(element).toBeInvalid(); - expect(element.find('option')[0].selected).toEqual(false); + scope.$apply(function() { + scope.required = false; }); + expect(element).toBeValid(); }); - describe('ngRequired', function() { - - it('should allow bindings on ngRequired', function() { - createSelect({ - 'ng-model': 'value', - 'ng-options': 'item.name for item in values', - 'ng-required': 'required' - }, true); - - - scope.$apply(function() { - scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; - scope.required = false; - }); + it('should treat an empty array as invalid when `multiple` attribute used', function() { + createSelect({ + 'ng-model': 'value', + 'ng-options': 'item.name for item in values', + 'ng-required': 'required', + 'multiple': '' + }, true); - element.val(''); - browserTrigger(element, 'change'); - expect(element).toBeValid(); + scope.$apply(function() { + scope.value = []; + scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; + scope.required = true; + }); + expect(element).toBeInvalid(); - scope.$apply(function() { - scope.required = true; - }); - expect(element).toBeInvalid(); + scope.$apply(function() { + // ngModelWatch does not set objectEquality flag + // array must be replaced in order to trigger $formatters + scope.value = [scope.values[0]]; + }); + expect(element).toBeValid(); + }); - scope.$apply(function() { - scope.value = scope.values[0]; - }); - expect(element).toBeValid(); - element.val(''); - browserTrigger(element, 'change'); - expect(element).toBeInvalid(); + it('should allow falsy values as values', function() { + createSelect({ + 'ng-model': 'value', + 'ng-options': 'item.value as item.name for item in values', + 'ng-required': 'required' + }, true); - scope.$apply(function() { - scope.required = false; - }); - expect(element).toBeValid(); + scope.$apply(function() { + scope.values = [{name: 'True', value: true}, {name: 'False', value: false}]; + scope.required = false; }); - - it('should treat an empty array as invalid when `multiple` attribute used', function() { - createSelect({ - 'ng-model': 'value', - 'ng-options': 'item.name for item in values', - 'ng-required': 'required', - 'multiple': '' - }, true); + setSelectValue(element, 2); + expect(element).toBeValid(); + expect(scope.value).toBe(false); scope.$apply(function() { - scope.value = []; - scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; scope.required = true; }); - expect(element).toBeInvalid(); + expect(element).toBeValid(); + expect(scope.value).toBe(false); + }); + }); - scope.$apply(function() { - // ngModelWatch does not set objectEquality flag - // array must be replaced in order to trigger $formatters - scope.value = [scope.values[0]]; - }); - expect(element).toBeValid(); + describe('ngModelCtrl', function() { + it('should prefix the model value with the word "the" using $parsers', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' }); + scope.form.select.$parsers.push(function(value) { + return 'the ' + value; + }); - it('should allow falsy values as values', function() { - createSelect({ - 'ng-model': 'value', - 'ng-options': 'item.value as item.name for item in values', - 'ng-required': 'required' - }, true); + setSelectValue(element, 3); + expect(scope.value).toBe('the third'); + expect(element).toEqualSelectValue('third'); + }); - scope.$apply(function() { - scope.values = [{name: 'True', value: true}, {name: 'False', value: false}]; - scope.required = false; - }); + it('should prefix the view value with the word "the" using $formatters', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'the first\', \'the second\', \'the third\', \'the fourth\']' + }); - element.val('1'); - browserTrigger(element, 'change'); - expect(element).toBeValid(); - expect(scope.value).toBe(false); + scope.form.select.$formatters.push(function(value) { + return 'the ' + value; + }); - scope.$apply(function() { - scope.required = true; - }); - expect(element).toBeValid(); - expect(scope.value).toBe(false); + scope.$apply(function() { + scope.value = 'third'; }); + expect(element).toEqualSelectValue('the third'); }); - describe('ngModelCtrl', function() { - it('should prefix the model value with the word "the" using $parsers', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$parsers.push(function(value) { - return 'the ' + value; - }); - - element.val('2'); - browserTrigger(element, 'change'); - expect(scope.value).toBe('the third'); - expect(element.val()).toBe('2'); + it('should fail validation when $validators fail', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' }); - it('should prefix the view value with the word "the" using $formatters', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'the first\', \'the second\', \'the third\', \'the fourth\']' - }); + scope.form.select.$validators.fail = function() { + return false; + }; - scope.form.select.$formatters.push(function(value) { - return 'the ' + value; - }); + setSelectValue(element, 3); + expect(element).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); + }); - scope.$apply(function() { - scope.value = 'third'; - }); - expect(element.val()).toBe('2'); + it('should pass validation when $validators pass', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' }); - it('should fail validation when $validators fail', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); + scope.form.select.$validators.pass = function() { + return true; + }; - scope.form.select.$validators.fail = function() { - return false; - }; + setSelectValue(element, 3); + expect(element).toBeValid(); + expect(scope.value).toBe('third'); + expect(element).toEqualSelectValue('third'); + }); - element.val('2'); - browserTrigger(element, 'change'); - expect(element).toBeInvalid(); - expect(scope.value).toBeUndefined(); - expect(element.val()).toBe('2'); + it('should fail validation when $asyncValidators fail', inject(function($q, $rootScope) { + var defer; + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' }); - it('should pass validation when $validators pass', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$validators.pass = function() { - return true; - }; - - element.val('2'); - browserTrigger(element, 'change'); - expect(element).toBeValid(); - expect(scope.value).toBe('third'); - expect(element.val()).toBe('2'); - }); + scope.form.select.$asyncValidators.async = function() { + defer = $q.defer(); + return defer.promise; + }; - it('should fail validation when $asyncValidators fail', inject(function($q, $rootScope) { - var defer; - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); + setSelectValue(element, 3); + expect(scope.form.select.$pending).toBeDefined(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); - scope.form.select.$asyncValidators.async = function() { - defer = $q.defer(); - return defer.promise; - }; + defer.reject(); + $rootScope.$digest(); + expect(scope.form.select.$pending).toBeUndefined(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); + })); - element.val('2'); - browserTrigger(element, 'change'); - expect(scope.form.select.$pending).toBeDefined(); - expect(scope.value).toBeUndefined(); - expect(element.val()).toBe('2'); - - defer.reject(); - $rootScope.$digest(); - expect(scope.form.select.$pending).toBeUndefined(); - expect(scope.value).toBeUndefined(); - expect(element.val()).toBe('2'); - })); - - it('should pass validation when $asyncValidators pass', inject(function($q, $rootScope) { - var defer; - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); + it('should pass validation when $asyncValidators pass', inject(function($q, $rootScope) { + var defer; + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' + }); - scope.form.select.$asyncValidators.async = function() { - defer = $q.defer(); - return defer.promise; - }; + scope.form.select.$asyncValidators.async = function() { + defer = $q.defer(); + return defer.promise; + }; - element.val('2'); - browserTrigger(element, 'change'); - expect(scope.form.select.$pending).toBeDefined(); - expect(scope.value).toBeUndefined(); - expect(element.val()).toBe('2'); + setSelectValue(element, 3); + expect(scope.form.select.$pending).toBeDefined(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); - defer.resolve(); - $rootScope.$digest(); - expect(scope.form.select.$pending).toBeUndefined(); - expect(scope.value).toBe('third'); - expect(element.val()).toBe('2'); - })); - }); + defer.resolve(); + $rootScope.$digest(); + expect(scope.form.select.$pending).toBeUndefined(); + expect(scope.value).toBe('third'); + expect(element).toEqualSelectValue('third'); + })); }); +}); describe('option', function() { it('should populate value attribute on OPTION', function() { compile(''); - expect(element).toEqualSelect(['? undefined:undefined ?'], 'abc'); + expect(element).toEqualSelect([unknownValue(undefined)], 'abc'); }); it('should ignore value if already exists', function() { compile(''); - expect(element).toEqualSelect(['? undefined:undefined ?'], 'abc'); + expect(element).toEqualSelect([unknownValue(undefined)], 'abc'); }); it('should set value even if self closing HTML', function() { From 578e8c4ecf9df2aeb812ea38784e9ccf074c97a0 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 10 Jan 2015 21:28:32 +0000 Subject: [PATCH 2/9] refact(ngOptions): move into its own file Since select is not aware of ngOptions, it makes sense to move it into its own file for more easy maintenance. --- angularFiles.js | 1 + src/ng/directive/ngOptions.js | 593 ++++++++++ src/ng/directive/select.js | 594 ---------- test/ng/directive/ngOptionsSpec.js | 1731 +++++++++++++++++++++++++++ test/ng/directive/selectSpec.js | 1738 ---------------------------- 5 files changed, 2325 insertions(+), 2332 deletions(-) create mode 100644 src/ng/directive/ngOptions.js create mode 100644 test/ng/directive/ngOptionsSpec.js diff --git a/angularFiles.js b/angularFiles.js index d0b0d8dd8504..696bd8e78ec8 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -65,6 +65,7 @@ var angularFiles = { 'src/ng/directive/ngList.js', 'src/ng/directive/ngModel.js', 'src/ng/directive/ngNonBindable.js', + 'src/ng/directive/ngOptions.js', 'src/ng/directive/ngPluralize.js', 'src/ng/directive/ngRepeat.js', 'src/ng/directive/ngShowHide.js', diff --git a/src/ng/directive/ngOptions.js b/src/ng/directive/ngOptions.js new file mode 100644 index 000000000000..a6a03e506469 --- /dev/null +++ b/src/ng/directive/ngOptions.js @@ -0,0 +1,593 @@ +'use strict'; + +/* global jqLiteRemove */ + +var ngOptionsMinErr = minErr('ngOptions'); + +/** + * @ngdoc directive + * @name ngOptions + * @restrict A + * + * @description + * + * The `ngOptions` attribute can be used to dynamically generate a list of `` - * 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`). With this the selection is preserved - * even when the options are recreated (e.g. reloaded from the server). - * - * @example - - - -
-
    -
  • - Name: - [X] -
  • -
  • - [add] -
  • -
-
- Color (null not allowed): -
- - Color (null allowed): - - -
- - Color grouped by shade: -
- - - Select bogus.
-
- Currently selected: {{ {selected_color:myColor} }} -
-
-
-
- - it('should check ng-options', function() { - expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('red'); - element.all(by.model('myColor')).first().click(); - element.all(by.css('select[ng-model="myColor"] option')).first().click(); - expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('black'); - element(by.css('.nullable select[ng-model="myColor"]')).click(); - element.all(by.css('.nullable select[ng-model="myColor"] option')).first().click(); - expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('null'); - }); - -
- */ - -// jshint maxlen: false - //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 -var NG_OPTIONS_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/; - // 1: value expression (valueFn) - // 2: label expression (displayFn) - // 3: group by expression (groupByFn) - // 4: array item variable name - // 5: object item key variable name - // 6: object item value variable name - // 7: collection expression - // 8: track by expression -// jshint maxlen: 100 - - -var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { - - function parseOptionsExpression(optionsExp, selectElement, scope) { - - var match = optionsExp.match(NG_OPTIONS_REGEXP); - if (!(match)) { - throw ngOptionsMinErr('iexp', - "Expected expression in form of " + - "'_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + - " but got '{0}'. Element: {1}", - optionsExp, startingTag(selectElement)); - } - - // Extract the parts from the ngOptions expression - - // The variable name for the value of the item in the collection - var valueName = match[4] || match[6]; - // The variable name for the key of the item in the collection - var keyName = match[5]; - - // An expression that generates the viewValue for an option if there is a label expression - var selectAs = / as /.test(match[0]) && match[1]; - // An expression that is used to track the id of each object in the options collection - var trackBy = match[8]; - // An expression that generates the viewValue for an option if there is no label expression - var valueFn = $parse(match[2] ? match[1] : valueName); - var selectAsFn = selectAs && $parse(selectAs); - var viewValueFn = selectAsFn || valueFn; - var trackByFn = trackBy ? $parse(trackBy) : - function getHashOfValue(value) { return hashKey(value); }; - var displayFn = $parse(match[2] || match[1]); - var groupByFn = $parse(match[3] || ''); - var valuesFn = $parse(match[7]); - - var locals = {}; - var getLocals = keyName ? function(value, key) { - locals[keyName] = key; - locals[valueName] = value; - return locals; - } : function(value) { - locals[valueName] = value; - return locals; - }; - - - function Option(selectValue, viewValue, label, group) { - this.selectValue = selectValue; - this.viewValue = viewValue; - this.label = label; - this.group = group; - } - - return { - getWatchables: function() { - // Create a collection of things that we would like to watch (watchedArray) - // so that they can all be watched using a single $watchCollection - // that only runs the handler once if anything changes - var watchedArray = []; - - var values = valuesFn(scope) || []; - - Object.keys(values).forEach(function getWatchable(key) { - var locals = getLocals(values[key], key); - var label = displayFn(scope, locals); - var selectValue = viewValueFn(scope, locals); - watchedArray.push(selectValue); - watchedArray.push(label); - }); - return watchedArray; - }, - - getOptions: function() { - - var optionItems = []; - var selectValueMap = {}; - - // The option values were already computed in the `getWatchables` fn, - // which must have been called to trigger `getOptions` - var optionValues = valuesFn(scope) || []; - - var keys = Object.keys(optionValues); - keys.forEach(function getOption(key) { - - // Ignore "angular" properties that start with $ or $$ - if (key.charAt(0) === '$') return; - - var value = optionValues[key]; - var locals = getLocals(value, key); - var viewValue = viewValueFn(scope, locals); - var selectValue = trackByFn(viewValue, locals); - var label = displayFn(scope, locals); - var group = groupByFn(scope, locals); - var optionItem = new Option(selectValue, viewValue, label, group); - - optionItems.push(optionItem); - selectValueMap[selectValue] = optionItem; - }); - - return { - items: optionItems, - selectValueMap: selectValueMap, - getOptionFromViewValue: function(value) { - return selectValueMap[trackByFn(value, getLocals(value))]; - } - }; - } - }; - } - - - // we can't just jqLite(''); + + scope.$apply(function() { + scope.blankVal = 'so blank'; + scope.values = [{name: 'A'}]; + }); + + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('blank is so blank'); + + scope.$apply(function() { + scope.blankVal = 'not so blank'; + }); + + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('blank is not so blank'); + }); + + + it('should support binding via ngBindTemplate directive', function() { + var option; + createSingleSelect(''); + + scope.$apply(function() { + scope.blankVal = 'so blank'; + scope.values = [{name: 'A'}]; + }); + + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('blank is so blank'); + }); + + + it('should support biding via ngBind attribute', function() { + var option; + createSingleSelect(''); + + scope.$apply(function() { + scope.blankVal = 'is blank'; + scope.values = [{name: 'A'}]; + }); + + // check blank option is first and is compiled + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); + expect(option.val()).toBe(''); + expect(option.text()).toBe('is blank'); + }); + + + it('should be rendered with the attributes preserved', function() { + var option; + createSingleSelect(''); + + scope.$apply(function() { + scope.blankVal = 'is blank'; + }); + + // check blank option is first and is compiled + option = element.find('option').eq(0); + expect(option.hasClass('coyote')).toBeTruthy(); + expect(option.attr('id')).toBe('road-runner'); + expect(option.attr('custom-attr')).toBe('custom-attr'); + }); + + it('should be selected, if it is available and no other option is selected', function() { + // selectedIndex is used here because jqLite incorrectly reports element.val() + scope.$apply(function() { + scope.values = [{name: 'A'}]; + }); + createSingleSelect(true); + // ensure the first option (the blank option) is selected + expect(element[0].selectedIndex).toEqual(0); + scope.$digest(); + // ensure the option has not changed following the digest + expect(element[0].selectedIndex).toEqual(0); + }); + }); + + + describe('on change', function() { + + it('should update model on change', function() { + createSingleSelect(); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + }); + + expect(element).toEqualSelectValue(scope.selected); + + setSelectValue(element, 1); + expect(scope.selected).toEqual(scope.values[1]); + }); + + + it('should update model on change through expression', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.id as item.name for item in values' + }); + + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + scope.selected = scope.values[0].id; + }); + + expect(element).toEqualSelectValue(scope.selected); + + setSelectValue(element, 1); + expect(scope.selected).toEqual(scope.values[1].id); + }); + + + it('should update model to null on change', function() { + createSingleSelect(true); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + }); + + element.val(''); + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(null); + }); + + + // Regression https://github.com/angular/angular.js/issues/7855 + it('should update the model with ng-change', function() { + createSelect({ + 'ng-change':'change()', + 'ng-model':'selected', + 'ng-options':'value for value in values' + }); + + scope.$apply(function() { + scope.values = ['A', 'B']; + scope.selected = 'A'; + }); + + scope.change = function() { + scope.selected = 'A'; + }; + + element.find('option')[1].selected = true; + + browserTrigger(element, 'change'); + expect(element.find('option')[0].selected).toBeTruthy(); + expect(scope.selected).toEqual('A'); + }); + }); + + describe('disabled blank', function() { + it('should select disabled blank by default', function() { + var html = ''; + scope.$apply(function() { + scope.choices = ['A', 'B', 'C']; + }); + + compile(html); + + var options = element.find('option'); + var optionToSelect = options.eq(0); + expect(optionToSelect.text()).toBe('Choose One'); + expect(optionToSelect.prop('selected')).toBe(true); + expect(element[0].value).toBe(''); + + dealoc(element); + }); + + + it('should select disabled blank by default when select is required', function() { + var html = ''; + scope.$apply(function() { + scope.choices = ['A', 'B', 'C']; + }); + + compile(html); + + var options = element.find('option'); + var optionToSelect = options.eq(0); + expect(optionToSelect.text()).toBe('Choose One'); + expect(optionToSelect.prop('selected')).toBe(true); + expect(element[0].value).toBe(''); + + dealoc(element); + }); + }); + + describe('select-many', function() { + + it('should read multiple selection', function() { + createMultiSelect(); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = []; + }); + + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeFalsy(); + expect(element.find('option')[1].selected).toBeFalsy(); + + scope.$apply(function() { + scope.selected.push(scope.values[1]); + }); + + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeFalsy(); + expect(element.find('option')[1].selected).toBeTruthy(); + + scope.$apply(function() { + scope.selected.push(scope.values[0]); + }); + + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeTruthy(); + expect(element.find('option')[1].selected).toBeTruthy(); + }); + + + it('should update model on change', function() { + createMultiSelect(); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = []; + }); + + element.find('option')[0].selected = true; + + browserTrigger(element, 'change'); + expect(scope.selected).toEqual([scope.values[0]]); + }); + + + it('should select from object', function() { + createSelect({ + 'ng-model':'selected', + 'multiple':true, + 'ng-options':'key as value for (key,value) in values' + }); + scope.values = {'0':'A', '1':'B'}; + + scope.selected = ['1']; + scope.$digest(); + expect(element.find('option')[1].selected).toBe(true); + + element.find('option')[0].selected = true; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(['0', '1']); + + element.find('option')[1].selected = false; + browserTrigger(element, 'change'); + expect(scope.selected).toEqual(['0']); + }); + + it('should deselect all options when model is emptied', function() { + createMultiSelect(); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = [scope.values[0]]; + }); + expect(element.find('option')[0].selected).toEqual(true); + + scope.$apply(function() { + scope.selected.pop(); + }); + + expect(element.find('option')[0].selected).toEqual(false); + }); + }); + + + describe('ngRequired', function() { + + it('should allow bindings on ngRequired', function() { + createSelect({ + 'ng-model': 'value', + 'ng-options': 'item.name for item in values', + 'ng-required': 'required' + }, true); + + + scope.$apply(function() { + scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; + scope.required = false; + }); + + element.val(''); + browserTrigger(element, 'change'); + expect(element).toBeValid(); + + scope.$apply(function() { + scope.required = true; + }); + expect(element).toBeInvalid(); + + scope.$apply(function() { + scope.value = scope.values[0]; + }); + expect(element).toBeValid(); + + element.val(''); + browserTrigger(element, 'change'); + expect(element).toBeInvalid(); + + scope.$apply(function() { + scope.required = false; + }); + expect(element).toBeValid(); + }); + + + it('should treat an empty array as invalid when `multiple` attribute used', function() { + createSelect({ + 'ng-model': 'value', + 'ng-options': 'item.name for item in values', + 'ng-required': 'required', + 'multiple': '' + }, true); + + scope.$apply(function() { + scope.value = []; + scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; + scope.required = true; + }); + expect(element).toBeInvalid(); + + scope.$apply(function() { + // ngModelWatch does not set objectEquality flag + // array must be replaced in order to trigger $formatters + scope.value = [scope.values[0]]; + }); + expect(element).toBeValid(); + }); + + + it('should allow falsy values as values', function() { + createSelect({ + 'ng-model': 'value', + 'ng-options': 'item.value as item.name for item in values', + 'ng-required': 'required' + }, true); + + scope.$apply(function() { + scope.values = [{name: 'True', value: true}, {name: 'False', value: false}]; + scope.required = false; + }); + + setSelectValue(element, 2); + expect(element).toBeValid(); + expect(scope.value).toBe(false); + + scope.$apply('required = true'); + expect(element).toBeValid(); + expect(scope.value).toBe(false); + }); + }); + + describe('ngModelCtrl', function() { + it('should prefix the model value with the word "the" using $parsers', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' + }); + + scope.form.select.$parsers.push(function(value) { + return 'the ' + value; + }); + + setSelectValue(element, 3); + expect(scope.value).toBe('the third'); + expect(element).toEqualSelectValue('third'); + }); + + it('should prefix the view value with the word "the" using $formatters', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'the first\', \'the second\', \'the third\', \'the fourth\']' + }); + + scope.form.select.$formatters.push(function(value) { + return 'the ' + value; + }); + + scope.$apply(function() { + scope.value = 'third'; + }); + expect(element).toEqualSelectValue('the third'); + }); + + it('should fail validation when $validators fail', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' + }); + + scope.form.select.$validators.fail = function() { + return false; + }; + + setSelectValue(element, 3); + expect(element).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); + }); + + it('should pass validation when $validators pass', function() { + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' + }); + + scope.form.select.$validators.pass = function() { + return true; + }; + + setSelectValue(element, 3); + expect(element).toBeValid(); + expect(scope.value).toBe('third'); + expect(element).toEqualSelectValue('third'); + }); + + it('should fail validation when $asyncValidators fail', inject(function($q, $rootScope) { + var defer; + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' + }); + + scope.form.select.$asyncValidators.async = function() { + defer = $q.defer(); + return defer.promise; + }; + + setSelectValue(element, 3); + expect(scope.form.select.$pending).toBeDefined(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); + + defer.reject(); + $rootScope.$digest(); + expect(scope.form.select.$pending).toBeUndefined(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); + })); + + it('should pass validation when $asyncValidators pass', inject(function($q, $rootScope) { + var defer; + createSelect({ + 'name': 'select', + 'ng-model': 'value', + 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' + }); + + scope.form.select.$asyncValidators.async = function() { + defer = $q.defer(); + return defer.promise; + }; + + setSelectValue(element, 3); + expect(scope.form.select.$pending).toBeDefined(); + expect(scope.value).toBeUndefined(); + expect(element).toEqualSelectValue('third'); + + defer.resolve(); + $rootScope.$digest(); + expect(scope.form.select.$pending).toBeUndefined(); + expect(scope.value).toBe('third'); + expect(element).toEqualSelectValue('third'); + })); + }); +}); diff --git a/test/ng/directive/selectSpec.js b/test/ng/directive/selectSpec.js index de7e7b859116..725c87ddec97 100644 --- a/test/ng/directive/selectSpec.js +++ b/test/ng/directive/selectSpec.js @@ -827,1744 +827,6 @@ describe('select', function() { }); - describe('ngOptions', function() { - - var scope, formElement, element, $compile; - - function compile(html) { - formElement = jqLite('
' + html + '
'); - element = formElement.find('select'); - $compile(formElement)(scope); - scope.$apply(); - } - - function setSelectValue(selectElement, optionIndex) { - var option = selectElement.find('option').eq(optionIndex); - selectElement.val(option.val()); - browserTrigger(element, 'change'); - } - - - beforeEach(function() { - this.addMatchers({ - toEqualSelectValue: function(value, multiple) { - var errors = []; - - if (multiple) { - value = value.map(function(val) { return hashKey(val); }); - } else { - value = hashKey(value); - } - - if (!equals(this.actual.val(), value)) { - errors.push('Expected select value "' + this.actual.val() + '" to equal "' + value + '"'); - } - this.message = function() { - return errors.join('\n'); - }; - - return errors.length === 0; - }, - toEqualOption: function(value, text, label) { - var errors = []; - var hash = hashKey(value); - if (this.actual.attr('value') !== hash) { - errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "' + hash + '"'); - } - if (text && this.actual.text() !== text) { - errors.push('Expected option text "' + this.actual.text() + '" to equal "' + text + '"'); - } - if (label && this.actual.attr('label') !== label) { - errors.push('Expected option label "' + this.actual.attr('label') + '" to equal "' + label + '"'); - } - - this.message = function() { - return errors.join('\n'); - }; - - return errors.length === 0; - }, - toEqualTrackedOption: function(value, text, label) { - var errors = []; - if (this.actual.attr('value') !== '' + value) { - errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "' + value + '"'); - } - if (text && this.actual.text() !== text) { - errors.push('Expected option text "' + this.actual.text() + '" to equal "' + text + '"'); - } - if (label && this.actual.attr('label') !== label) { - errors.push('Expected option label "' + this.actual.attr('label') + '" to equal "' + label + '"'); - } - - this.message = function() { - return errors.join('\n'); - }; - - return errors.length === 0; - }, - toEqualUnknownOption: function() { - var errors = []; - if (this.actual.attr('value') !== '?') { - errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "?"'); - } - - this.message = function() { - return errors.join('\n'); - }; - - return errors.length === 0; - }, - toEqualUnknownValue: function(value) { - var errors = []; - if (this.actual !== '?') { - errors.push('Expected select value "' + this.actual + '" to equal "?"'); - } - - this.message = function() { - return errors.join('\n'); - }; - - return errors.length === 0; - } - }); - }); - - beforeEach(inject(function($rootScope, _$compile_) { - scope = $rootScope.$new(); //create a child scope because the root scope can't be $destroy-ed - $compile = _$compile_; - formElement = element = null; - })); - - - afterEach(function() { - scope.$destroy(); //disables unknown option work during destruction - dealoc(formElement); - }); - - function createSelect(attrs, blank, unknown) { - var html = 'blank') : '') + - (unknown ? (isString(unknown) ? unknown : '') : '') + - ''; - - compile(html); - } - - function createSingleSelect(blank, unknown) { - createSelect({ - 'ng-model':'selected', - 'ng-options':'value.name for value in values' - }, blank, unknown); - } - - function createMultiSelect(blank, unknown) { - createSelect({ - 'ng-model':'selected', - 'multiple':true, - 'ng-options':'value.name for value in values' - }, blank, unknown); - } - - - describe('selectAs expression', function() { - beforeEach(function() { - scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]; - scope.obj = {'10': {score: 10, label: 'ten'}, '20': {score: 20, label: 'twenty'}}; - }); - - it('should support single select with array source', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.label for item in arr' - }); - - scope.$apply(function() { - scope.selected = 10; - }); - expect(element).toEqualSelectValue(10); - - setSelectValue(element, 1); - expect(scope.selected).toBe(20); - }); - - - it('should support multi select with array source', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'item.id as item.label for item in arr' - }); - - scope.$apply(function() { - scope.selected = [10,20]; - }); - expect(element).toEqualSelectValue([10,20], true); - expect(scope.selected).toEqual([10,20]); - - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([20]); - expect(element).toEqualSelectValue([20], true); - }); - - - it('should support single select with object source', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.score as val.label for (key, val) in obj' - }); - - scope.$apply(function() { - scope.selected = 10; - }); - expect(element).toEqualSelectValue(10); - - setSelectValue(element, 1); - expect(scope.selected).toBe(20); - }); - - - it('should support multi select with object source', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'val.score as val.label for (key, val) in obj' - }); - - scope.$apply(function() { - scope.selected = [10,20]; - }); - expect(element).toEqualSelectValue([10,20], true); - - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([20]); - expect(element).toEqualSelectValue([20], true); - }); - }); - - - describe('trackBy expression', function() { - beforeEach(function() { - scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]; - scope.obj = {'1': {score: 10, label: 'ten'}, '2': {score: 20, label: 'twenty'}}; - }); - - - it('should set the result of track by expression to element value', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.label for item in arr track by item.id' - }); - - expect(element.val()).toEqualUnknownValue(); - - scope.$apply(function() { - scope.selected = scope.arr[0]; - }); - expect(element.val()).toBe('10'); - - scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; - }); - expect(element.val()).toBe('10'); - - element.children()[1].selected = 'selected'; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.arr[1]); - }); - - - it('should use the tracked expression as option value', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.label for item in arr track by item.id' - }); - - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualUnknownOption(); - expect(options.eq(1)).toEqualTrackedOption(10, 'ten'); - expect(options.eq(2)).toEqualTrackedOption(20, 'twenty'); - }); - - it('should preserve value even when reference has changed (single&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.label for item in arr track by item.id' - }); - - scope.$apply(function() { - scope.selected = scope.arr[0]; - }); - expect(element.val()).toBe('10'); - - scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; - }); - expect(element.val()).toBe('10'); - - element.children()[1].selected = 1; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.arr[1]); - }); - - - it('should preserve value even when reference has changed (multi&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'item.label for item in arr track by item.id' - }); - - scope.$apply(function() { - scope.selected = scope.arr; - }); - expect(element.val()).toEqual(['10','20']); - - scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; - }); - expect(element.val()).toEqual(['10','20']); - - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.arr[1]]); - }); - - - it('should preserve value even when reference has changed (single&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.label for (key, val) in obj track by val.score' - }); - - scope.$apply(function() { - scope.selected = scope.obj['1']; - }); - expect(element.val()).toBe('10'); - - scope.$apply(function() { - scope.obj['1'] = {score: 10, label: 'ten'}; - }); - expect(element.val()).toBe('10'); - - setSelectValue(element, 1); - expect(scope.selected).toBe(scope.obj['2']); - }); - - - it('should preserve value even when reference has changed (multi&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'val.label for (key, val) in obj track by val.score' - }); - - scope.$apply(function() { - scope.selected = [scope.obj['1']]; - }); - expect(element.val()).toEqual(['10']); - - scope.$apply(function() { - scope.obj['1'] = {score: 10, label: 'ten'}; - }); - expect(element.val()).toEqual(['10']); - - element.children()[1].selected = 'selected'; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.obj['1'], scope.obj['2']]); - }); - }); - - - /** - * This behavior is broken and should probably be cleaned up later as track by and select as - * aren't compatible. - */ - describe('selectAs+trackBy expression', function() { - beforeEach(function() { - scope.arr = [{subItem: {label: 'ten', id: 10}}, {subItem: {label: 'twenty', id: 20}}]; - scope.obj = {'10': {subItem: {id: 10, label: 'ten'}}, '20': {subItem: {id: 20, label: 'twenty'}}}; - }); - - - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (single&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.subItem as item.subItem.label for item in arr track by (item.id || item.subItem.id)' - }); - - // First test model -> view - - scope.$apply(function() { - scope.selected = scope.arr[0].subItem; - }); - expect(element.val()).toEqual('10'); - - scope.$apply(function() { - scope.selected = scope.arr[1].subItem; - }); - expect(element.val()).toEqual('20'); - - // Now test view -> model - - element.val('10'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(scope.arr[0].subItem); - - // Now reload the array - scope.$apply(function() { - scope.arr = [{ - subItem: {label: 'new ten', id: 10} - },{ - subItem: {label: 'new twenty', id: 20} - }]; - }); - expect(element.val()).toBe('10'); - expect(scope.selected.id).toBe(10); - }); - - - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (multiple&array)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'item.subItem as item.subItem.label for item in arr track by (item.id || item.subItem.id)' - }); - - // First test model -> view - - scope.$apply(function() { - scope.selected = [scope.arr[0].subItem]; - }); - expect(element.val()).toEqual(['10']); - - scope.$apply(function() { - scope.selected = [scope.arr[1].subItem]; - }); - expect(element.val()).toEqual(['20']); - - // Now test view -> model - - element.find('option')[0].selected = true; - element.find('option')[1].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.arr[0].subItem]); - - // Now reload the array - scope.$apply(function() { - scope.arr = [{ - subItem: {label: 'new ten', id: 10} - },{ - subItem: {label: 'new twenty', id: 20} - }]; - }); - expect(element.val()).toEqual(['10']); - expect(scope.selected[0].id).toEqual(10); - expect(scope.selected.length).toBe(1); - }); - - - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (multiple&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'multiple': true, - 'ng-options': 'val.subItem as val.subItem.label for (key, val) in obj track by (val.id || val.subItem.id)' - }); - - // First test model -> view - - scope.$apply(function() { - scope.selected = [scope.obj['10'].subItem]; - }); - expect(element.val()).toEqual(['10']); - - - scope.$apply(function() { - scope.selected = [scope.obj['10'].subItem]; - }); - expect(element.val()).toEqual(['10']); - - // Now test view -> model - - element.find('option')[0].selected = true; - element.find('option')[1].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.obj['10'].subItem]); - - // Now reload the object - scope.$apply(function() { - scope.obj = { - '10': { - subItem: {label: 'new ten', id: 10} - }, - '20': { - subItem: {label: 'new twenty', id: 20} - } - }; - }); - expect(element.val()).toEqual(['10']); - expect(scope.selected[0].id).toBe(10); - expect(scope.selected.length).toBe(1); - }); - - - it('It should use the "value" variable to represent items in the array as well as for the ' + - 'selected values in track by expression (single&object)', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.subItem as val.subItem.label for (key, val) in obj track by (val.id || val.subItem.id)' - }); - - // First test model -> view - - scope.$apply(function() { - scope.selected = scope.obj['10'].subItem; - }); - expect(element.val()).toEqual('10'); - - - scope.$apply(function() { - scope.selected = scope.obj['10'].subItem; - }); - expect(element.val()).toEqual('10'); - - // Now test view -> model - - element.find('option')[0].selected = true; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.obj['10'].subItem); - - // Now reload the object - scope.$apply(function() { - scope.obj = { - '10': { - subItem: {label: 'new ten', id: 10} - }, - '20': { - subItem: {label: 'new twenty', id: 20} - } - }; - }); - expect(element.val()).toEqual('10'); - expect(scope.selected.id).toBe(10); - }); - }); - - - it('should throw when not formated "? for ? in ?"', function() { - expect(function() { - compile('')(scope); - }).toThrowMinErr('ngOptions', 'iexp', /Expected expression in form of/); - }); - - - it('should render a list', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[1]; - }); - - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption(scope.values[0], 'A'); - expect(options.eq(1)).toEqualOption(scope.values[1], 'B'); - expect(options.eq(2)).toEqualOption(scope.values[2], 'C'); - expect(options[1].selected).toEqual(true); - }); - - - it('should render zero as a valid display value', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 0}, {name: 1}, {name: 2}]; - scope.selected = scope.values[0]; - }); - - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption(scope.values[0], '0'); - expect(options.eq(1)).toEqualOption(scope.values[1], '1'); - expect(options.eq(2)).toEqualOption(scope.values[2], '2'); - }); - - - it('should render an object', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'value as key for (key, value) in object' - }); - - scope.$apply(function() { - scope.object = {'red': 'FF0000', 'green': '00FF00', 'blue': '0000FF'}; - scope.selected = scope.object.green; - }); - - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption('FF0000', 'red'); - expect(options.eq(1)).toEqualOption('00FF00', 'green'); - expect(options.eq(2)).toEqualOption('0000FF', 'blue'); - expect(options[1].selected).toEqual(true); - - scope.$apply(function() { - scope.object.azur = '8888FF'; - }); - - options = element.find('option'); - expect(options[1].selected).toEqual(true); - - scope.$apply(function() { - scope.selected = scope.object.azur; - }); - - options = element.find('option'); - expect(options[3].selected).toEqual(true); - - }); - - - it('should grow list', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = []; - }); - - expect(element.find('option').length).toEqual(1); // because we add special unknown option - expect(element.find('option').eq(0)).toEqualUnknownOption(); - - scope.$apply(function() { - scope.values.push({name:'A'}); - scope.selected = scope.values[0]; - }); - - expect(element.find('option').length).toEqual(1); - expect(element.find('option')).toEqualOption(scope.values[0], 'A'); - - scope.$apply(function() { - scope.values.push({name:'B'}); - }); - - expect(element.find('option').length).toEqual(2); - expect(element.find('option').eq(0)).toEqualOption(scope.values[0], 'A'); - expect(element.find('option').eq(1)).toEqualOption(scope.values[1], 'B'); - }); - - - it('should shrink list', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - }); - - expect(element.find('option').length).toEqual(3); - - scope.$apply(function() { - scope.values.pop(); - }); - - expect(element.find('option').length).toEqual(2); - expect(element.find('option').eq(0)).toEqualOption(scope.values[0], 'A'); - expect(element.find('option').eq(1)).toEqualOption(scope.values[1], 'B'); - - scope.$apply(function() { - scope.values.pop(); - }); - - expect(element.find('option').length).toEqual(1); - expect(element.find('option')).toEqualOption(scope.values[0], 'A'); - - scope.$apply(function() { - scope.values.pop(); - scope.selected = null; - }); - - expect(element.find('option').length).toEqual(1); // we add back the special empty option - }); - - - it('should shrink and then grow list', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - }); - - expect(element.find('option').length).toEqual(3); - - scope.$apply(function() { - scope.values = [{name: '1'}, {name: '2'}]; - scope.selected = scope.values[0]; - }); - - expect(element.find('option').length).toEqual(2); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); - - expect(element.find('option').length).toEqual(3); - }); - - - it('should update list', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); - - scope.$apply(function() { - scope.values = [{name: 'B'}, {name: 'C'}, {name: 'D'}]; - scope.selected = scope.values[0]; - }); - - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(0)).toEqualOption(scope.values[0], 'B'); - expect(options.eq(1)).toEqualOption(scope.values[1], 'C'); - expect(options.eq(2)).toEqualOption(scope.values[2], 'D'); - }); - - it('should preserve pre-existing empty option', function() { - createSingleSelect(true); - - scope.$apply(function() { - scope.values = []; - }); - expect(element.find('option').length).toEqual(1); - - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = scope.values[0]; - }); - - expect(element.find('option').length).toEqual(2); - expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); - expect(jqLite(element.find('option')[1]).text()).toEqual('A'); - - scope.$apply(function() { - scope.values = []; - scope.selected = null; - }); - - expect(element.find('option').length).toEqual(1); - expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); - }); - - - it('should ignore $ and $$ properties', function() { - createSelect({ - 'ng-options': 'key as value for (key, value) in object', - 'ng-model': 'selected' - }); - - scope.$apply(function() { - scope.object = {'regularProperty': 'visible', '$$private': 'invisible', '$property': 'invisible'}; - scope.selected = 'regularProperty'; - }); - - var options = element.find('option'); - expect(options.length).toEqual(1); - expect(options.eq(0)).toEqualOption('regularProperty', 'visible'); - }); - - - it('should allow expressions over multiple lines', function() { - scope.isNotFoo = function(item) { - return item.name !== 'Foo'; - }; - - createSelect({ - 'ng-options': 'key.id\n' + - 'for key in values\n' + - '| filter:isNotFoo', - 'ng-model': 'selected' - }); - - scope.$apply(function() { - scope.values = [{'id': 1, 'name': 'Foo'}, - {'id': 2, 'name': 'Bar'}, - {'id': 3, 'name': 'Baz'}]; - scope.selected = scope.values[0]; - }); - - var options = element.find('option'); - expect(options.length).toEqual(3); - expect(options.eq(1)).toEqualOption(scope.values[1], '2'); - expect(options.eq(2)).toEqualOption(scope.values[2], '3'); - }); - - - it('should not update selected property of an option element on digest with no change event', - function() { - // ng-options="value.name for value in values" - // ng-model="selected" - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); - - var options = element.find('option'); - - expect(scope.selected).toEqual(jasmine.objectContaining({ name: 'A' })); - expect(options.eq(0).prop('selected')).toBe(true); - expect(options.eq(1).prop('selected')).toBe(false); - - var optionToSelect = options.eq(1); - - expect(optionToSelect.text()).toBe('B'); - - optionToSelect.prop('selected', true); - scope.$digest(); - - expect(optionToSelect.prop('selected')).toBe(true); - expect(scope.selected).toBe(scope.values[0]); - }); - - - - it('should not be set when an option is selected and options are set asynchronously', - inject(function($timeout) { - compile(''); - - scope.$apply(function() { - scope.model = 0; - }); - - $timeout(function() { - scope.options = [ - {id: 0, label: 'x'}, - {id: 1, label: 'y'} - ]; - }, 0); - - $timeout.flush(); - - var options = element.find('option'); - - expect(options.length).toEqual(2); - expect(options.eq(0)).toEqualOption(0, 'x'); - expect(options.eq(1)).toEqualOption(1, 'y'); - }) - ); - - - // bug fix #9621 - it('should update the label property', function() { - // ng-options="value.name for value in values" - // ng-model="selected" - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; - scope.selected = scope.values[0]; - }); - - var options = element.find('option'); - expect(options.eq(0).prop('label')).toEqual('A'); - expect(options.eq(1).prop('label')).toEqual('B'); - expect(options.eq(2).prop('label')).toEqual('C'); - }); - - - // bug fix #9714 - it('should select the matching option when the options are updated', function() { - - // first set up a select with no options - scope.selected = ''; - createSelect({ - 'ng-options': 'val.id as val.label for val in values', - 'ng-model': 'selected' - }); - var options = element.find('option'); - // we expect the selected option to be the "unknown" option - expect(options.eq(0)).toEqualUnknownOption(''); - expect(options.eq(0).prop('selected')).toEqual(true); - - // now add some real options - one of which matches the selected value - scope.$apply('values = [{id:"",label:"A"},{id:"1",label:"B"},{id:"2",label:"C"}]'); - - // we expect the selected option to be the one that matches the correct item - // and for the unknown option to have been removed - options = element.find('option'); - expect(element).toEqualSelectValue(''); - expect(options.eq(0)).toEqualOption('','A'); - }); - - - describe('binding', function() { - - it('should bind to scope value', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - }); - - expect(element).toEqualSelectValue(scope.selected); - - scope.$apply(function() { - scope.selected = scope.values[1]; - }); - - expect(element).toEqualSelectValue(scope.selected); - }); - - - it('should bind to scope value and group', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.name group by item.group for item in values' - }); - - scope.$apply(function() { - scope.values = [{name: 'A'}, - {name: 'B', group: 'first'}, - {name: 'C', group: 'second'}, - {name: 'D', group: 'first'}, - {name: 'E', group: 'second'}]; - scope.selected = scope.values[3]; - }); - - expect(element).toEqualSelectValue(scope.selected); - - var first = jqLite(element.find('optgroup')[0]); - var b = jqLite(first.find('option')[0]); - var d = jqLite(first.find('option')[1]); - expect(first.attr('label')).toEqual('first'); - expect(b.text()).toEqual('B'); - expect(d.text()).toEqual('D'); - - var second = jqLite(element.find('optgroup')[1]); - var c = jqLite(second.find('option')[0]); - var e = jqLite(second.find('option')[1]); - expect(second.attr('label')).toEqual('second'); - expect(c.text()).toEqual('C'); - expect(e.text()).toEqual('E'); - - scope.$apply(function() { - scope.selected = scope.values[0]; - }); - - expect(element).toEqualSelectValue(scope.selected); - }); - - - it('should bind to scope value and track/identify objects', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': '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 = scope.values[1]; - }); - - 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 expression', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.name for item in values' - }); - - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - scope.selected = scope.values[0].id; - }); - - expect(element).toEqualSelectValue(scope.selected); - - scope.$apply(function() { - scope.selected = scope.values[1].id; - }); - - expect(element).toEqualSelectValue(scope.selected); - }); - - it('should update options in the DOM', function() { - compile( - '' - ); - - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - scope.selected = scope.values[0].id; - }); - - scope.$apply(function() { - scope.values[0].name = 'C'; - }); - - var options = element.find('option'); - expect(options.length).toEqual(2); - expect(options.eq(0)).toEqualOption(10, 'C'); - expect(options.eq(1)).toEqualOption(20, 'B'); - }); - - - it('should update options in the DOM from object source', function() { - compile( - '' - ); - - scope.$apply(function() { - scope.values = {a: {id: 10, name: 'A'}, b: {id: 20, name: 'B'}}; - scope.selected = scope.values.a.id; - }); - - scope.$apply(function() { - scope.values.a.name = 'C'; - }); - - var options = element.find('option'); - expect(options.length).toEqual(2); - expect(options.eq(0)).toEqualOption(10, 'C'); - expect(options.eq(1)).toEqualOption(20, 'B'); - }); - - - it('should bind to object key', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'key as value for (key, value) in object' - }); - - scope.$apply(function() { - scope.object = {red: 'FF0000', green: '00FF00', blue: '0000FF'}; - scope.selected = 'green'; - }); - - expect(element).toEqualSelectValue(scope.selected); - - scope.$apply(function() { - scope.selected = 'blue'; - }); - - expect(element).toEqualSelectValue(scope.selected); - }); - - - it('should bind to object value', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'value as key for (key, value) in object' - }); - - scope.$apply(function() { - scope.object = {red: 'FF0000', green: '00FF00', blue:'0000FF'}; - scope.selected = '00FF00'; - }); - - expect(element).toEqualSelectValue(scope.selected); - - scope.$apply(function() { - scope.selected = '0000FF'; - }); - - expect(element).toEqualSelectValue(scope.selected); - }); - - - it('should insert a blank option if bound to null', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = null; - }); - - expect(element.find('option').length).toEqual(2); - expect(element.val()).toEqual(''); - expect(jqLite(element.find('option')[0]).val()).toEqual(''); - - scope.$apply(function() { - scope.selected = scope.values[0]; - }); - - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').length).toEqual(1); - }); - - - it('should reuse blank option if bound to null', function() { - createSingleSelect(true); - - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = null; - }); - - expect(element.find('option').length).toEqual(2); - expect(element.val()).toEqual(''); - expect(jqLite(element.find('option')[0]).val()).toEqual(''); - - scope.$apply(function() { - scope.selected = scope.values[0]; - }); - - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').length).toEqual(2); - }); - - - it('should insert a unknown option if bound to something not in the list', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}]; - scope.selected = {}; - }); - - expect(element.find('option').length).toEqual(2); - expect(element.val()).toEqualUnknownValue(scope.selected); - expect(element.find('option').eq(0)).toEqualUnknownOption(scope.selected); - - scope.$apply(function() { - scope.selected = scope.values[0]; - }); - - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').length).toEqual(1); - }); - - - it('should select correct input if previously selected option was "?"', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = {}; - }); - - expect(element.find('option').length).toEqual(3); - expect(element.val()).toEqualUnknownValue(); - expect(element.find('option').eq(0)).toEqualUnknownOption(); - - browserTrigger(element.find('option').eq(1)); - expect(element.find('option').length).toEqual(2); - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').eq(0).prop('selected')).toBeTruthy(); - }); - - - it('should use exact same values as values in scope with one-time bindings', function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'value.name for value in ::values' - }); - - browserTrigger(element.find('option').eq(1)); - - expect(scope.selected).toBe(scope.values[1]); - }); - - - it('should ensure that at least one option element has the "selected" attribute', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.name for item in values' - }); - - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - }); - expect(element.val()).toEqualUnknownValue(); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - - scope.$apply(function() { - scope.selected = 10; - }); - // Here the ? option should disappear and the first real option should have selected attribute - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - - // Here the selected value is changed and we change the selected attribute - scope.$apply(function() { - scope.selected = 20; - }); - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').eq(1).attr('selected')).toEqual('selected'); - - scope.$apply(function() { - scope.values.push({id: 30, name: 'C'}); - }); - expect(element).toEqualSelectValue(scope.selected); - expect(element.find('option').eq(1).attr('selected')).toEqual('selected'); - - // Here the ? option should reappear and have selected attribute - scope.$apply(function() { - scope.selected = undefined; - }); - expect(element.val()).toEqualUnknownValue(); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - }); - - - it('should select the correct option for selectAs and falsy values', function() { - scope.values = [{value: 0, label: 'zero'}, {value: 1, label: 'one'}]; - scope.selected = ''; - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'option.value as option.label for option in values' - }); - - var option = element.find('option').eq(0); - expect(option).toEqualUnknownOption(); - }); - }); - - - describe('blank option', function() { - - it('should be compiled as template, be watched and updated', function() { - var option; - createSingleSelect(''); - - scope.$apply(function() { - scope.blankVal = 'so blank'; - scope.values = [{name: 'A'}]; - }); - - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('blank is so blank'); - - scope.$apply(function() { - scope.blankVal = 'not so blank'; - }); - - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('blank is not so blank'); - }); - - - it('should support binding via ngBindTemplate directive', function() { - var option; - createSingleSelect(''); - - scope.$apply(function() { - scope.blankVal = 'so blank'; - scope.values = [{name: 'A'}]; - }); - - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('blank is so blank'); - }); - - - it('should support biding via ngBind attribute', function() { - var option; - createSingleSelect(''); - - scope.$apply(function() { - scope.blankVal = 'is blank'; - scope.values = [{name: 'A'}]; - }); - - // check blank option is first and is compiled - expect(element.find('option').length).toBe(2); - option = element.find('option').eq(0); - expect(option.val()).toBe(''); - expect(option.text()).toBe('is blank'); - }); - - - it('should be rendered with the attributes preserved', function() { - var option; - createSingleSelect(''); - - scope.$apply(function() { - scope.blankVal = 'is blank'; - }); - - // check blank option is first and is compiled - option = element.find('option').eq(0); - expect(option.hasClass('coyote')).toBeTruthy(); - expect(option.attr('id')).toBe('road-runner'); - expect(option.attr('custom-attr')).toBe('custom-attr'); - }); - - it('should be selected, if it is available and no other option is selected', function() { - // selectedIndex is used here because jqLite incorrectly reports element.val() - scope.$apply(function() { - scope.values = [{name: 'A'}]; - }); - createSingleSelect(true); - // ensure the first option (the blank option) is selected - expect(element[0].selectedIndex).toEqual(0); - scope.$digest(); - // ensure the option has not changed following the digest - expect(element[0].selectedIndex).toEqual(0); - }); - }); - - - describe('on change', function() { - - it('should update model on change', function() { - createSingleSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - }); - - expect(element).toEqualSelectValue(scope.selected); - - setSelectValue(element, 1); - expect(scope.selected).toEqual(scope.values[1]); - }); - - - it('should update model on change through expression', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.name for item in values' - }); - - scope.$apply(function() { - scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; - scope.selected = scope.values[0].id; - }); - - expect(element).toEqualSelectValue(scope.selected); - - setSelectValue(element, 1); - expect(scope.selected).toEqual(scope.values[1].id); - }); - - - it('should update model to null on change', function() { - createSingleSelect(true); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = scope.values[0]; - }); - - element.val(''); - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(null); - }); - - - // Regression https://github.com/angular/angular.js/issues/7855 - it('should update the model with ng-change', function() { - createSelect({ - 'ng-change':'change()', - 'ng-model':'selected', - 'ng-options':'value for value in values' - }); - - scope.$apply(function() { - scope.values = ['A', 'B']; - scope.selected = 'A'; - }); - - scope.change = function() { - scope.selected = 'A'; - }; - - element.find('option')[1].selected = true; - - browserTrigger(element, 'change'); - expect(element.find('option')[0].selected).toBeTruthy(); - expect(scope.selected).toEqual('A'); - }); - }); - - describe('disabled blank', function() { - it('should select disabled blank by default', function() { - var html = ''; - scope.$apply(function() { - scope.choices = ['A', 'B', 'C']; - }); - - compile(html); - - var options = element.find('option'); - var optionToSelect = options.eq(0); - expect(optionToSelect.text()).toBe('Choose One'); - expect(optionToSelect.prop('selected')).toBe(true); - expect(element[0].value).toBe(''); - - dealoc(element); - }); - - - it('should select disabled blank by default when select is required', function() { - var html = ''; - scope.$apply(function() { - scope.choices = ['A', 'B', 'C']; - }); - - compile(html); - - var options = element.find('option'); - var optionToSelect = options.eq(0); - expect(optionToSelect.text()).toBe('Choose One'); - expect(optionToSelect.prop('selected')).toBe(true); - expect(element[0].value).toBe(''); - - dealoc(element); - }); - }); - - describe('select-many', function() { - - it('should read multiple selection', function() { - createMultiSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = []; - }); - - expect(element.find('option').length).toEqual(2); - expect(element.find('option')[0].selected).toBeFalsy(); - expect(element.find('option')[1].selected).toBeFalsy(); - - scope.$apply(function() { - scope.selected.push(scope.values[1]); - }); - - expect(element.find('option').length).toEqual(2); - expect(element.find('option')[0].selected).toBeFalsy(); - expect(element.find('option')[1].selected).toBeTruthy(); - - scope.$apply(function() { - scope.selected.push(scope.values[0]); - }); - - expect(element.find('option').length).toEqual(2); - expect(element.find('option')[0].selected).toBeTruthy(); - expect(element.find('option')[1].selected).toBeTruthy(); - }); - - - it('should update model on change', function() { - createMultiSelect(); - - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = []; - }); - - element.find('option')[0].selected = true; - - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.values[0]]); - }); - - - it('should select from object', function() { - createSelect({ - 'ng-model':'selected', - 'multiple':true, - 'ng-options':'key as value for (key,value) in values' - }); - scope.values = {'0':'A', '1':'B'}; - - scope.selected = ['1']; - scope.$digest(); - expect(element.find('option')[1].selected).toBe(true); - - element.find('option')[0].selected = true; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(['0', '1']); - - element.find('option')[1].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(['0']); - }); - - it('should deselect all options when model is emptied', function() { - createMultiSelect(); - scope.$apply(function() { - scope.values = [{name: 'A'}, {name: 'B'}]; - scope.selected = [scope.values[0]]; - }); - expect(element.find('option')[0].selected).toEqual(true); - - scope.$apply(function() { - scope.selected.pop(); - }); - - expect(element.find('option')[0].selected).toEqual(false); - }); - }); - - - describe('ngRequired', function() { - - it('should allow bindings on ngRequired', function() { - createSelect({ - 'ng-model': 'value', - 'ng-options': 'item.name for item in values', - 'ng-required': 'required' - }, true); - - - scope.$apply(function() { - scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; - scope.required = false; - }); - - element.val(''); - browserTrigger(element, 'change'); - expect(element).toBeValid(); - - scope.$apply(function() { - scope.required = true; - }); - expect(element).toBeInvalid(); - - scope.$apply(function() { - scope.value = scope.values[0]; - }); - expect(element).toBeValid(); - - element.val(''); - browserTrigger(element, 'change'); - expect(element).toBeInvalid(); - - scope.$apply(function() { - scope.required = false; - }); - expect(element).toBeValid(); - }); - - - it('should treat an empty array as invalid when `multiple` attribute used', function() { - createSelect({ - 'ng-model': 'value', - 'ng-options': 'item.name for item in values', - 'ng-required': 'required', - 'multiple': '' - }, true); - - scope.$apply(function() { - scope.value = []; - scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; - scope.required = true; - }); - expect(element).toBeInvalid(); - - scope.$apply(function() { - // ngModelWatch does not set objectEquality flag - // array must be replaced in order to trigger $formatters - scope.value = [scope.values[0]]; - }); - expect(element).toBeValid(); - }); - - - it('should allow falsy values as values', function() { - createSelect({ - 'ng-model': 'value', - 'ng-options': 'item.value as item.name for item in values', - 'ng-required': 'required' - }, true); - - scope.$apply(function() { - scope.values = [{name: 'True', value: true}, {name: 'False', value: false}]; - scope.required = false; - }); - - setSelectValue(element, 2); - expect(element).toBeValid(); - expect(scope.value).toBe(false); - - scope.$apply(function() { - scope.required = true; - }); - expect(element).toBeValid(); - expect(scope.value).toBe(false); - }); - }); - - describe('ngModelCtrl', function() { - it('should prefix the model value with the word "the" using $parsers', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$parsers.push(function(value) { - return 'the ' + value; - }); - - setSelectValue(element, 3); - expect(scope.value).toBe('the third'); - expect(element).toEqualSelectValue('third'); - }); - - it('should prefix the view value with the word "the" using $formatters', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'the first\', \'the second\', \'the third\', \'the fourth\']' - }); - - scope.form.select.$formatters.push(function(value) { - return 'the ' + value; - }); - - scope.$apply(function() { - scope.value = 'third'; - }); - expect(element).toEqualSelectValue('the third'); - }); - - it('should fail validation when $validators fail', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$validators.fail = function() { - return false; - }; - - setSelectValue(element, 3); - expect(element).toBeInvalid(); - expect(scope.value).toBeUndefined(); - expect(element).toEqualSelectValue('third'); - }); - - it('should pass validation when $validators pass', function() { - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$validators.pass = function() { - return true; - }; - - setSelectValue(element, 3); - expect(element).toBeValid(); - expect(scope.value).toBe('third'); - expect(element).toEqualSelectValue('third'); - }); - - it('should fail validation when $asyncValidators fail', inject(function($q, $rootScope) { - var defer; - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$asyncValidators.async = function() { - defer = $q.defer(); - return defer.promise; - }; - - setSelectValue(element, 3); - expect(scope.form.select.$pending).toBeDefined(); - expect(scope.value).toBeUndefined(); - expect(element).toEqualSelectValue('third'); - - defer.reject(); - $rootScope.$digest(); - expect(scope.form.select.$pending).toBeUndefined(); - expect(scope.value).toBeUndefined(); - expect(element).toEqualSelectValue('third'); - })); - - it('should pass validation when $asyncValidators pass', inject(function($q, $rootScope) { - var defer; - createSelect({ - 'name': 'select', - 'ng-model': 'value', - 'ng-options': 'item for item in [\'first\', \'second\', \'third\', \'fourth\']' - }); - - scope.form.select.$asyncValidators.async = function() { - defer = $q.defer(); - return defer.promise; - }; - - setSelectValue(element, 3); - expect(scope.form.select.$pending).toBeDefined(); - expect(scope.value).toBeUndefined(); - expect(element).toEqualSelectValue('third'); - - defer.resolve(); - $rootScope.$digest(); - expect(scope.form.select.$pending).toBeUndefined(); - expect(scope.value).toBe('third'); - expect(element).toEqualSelectValue('third'); - })); - }); -}); - - describe('option', function() { it('should populate value attribute on OPTION', function() { From b4b682243394bde83c53340a06fd0072812c8349 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 10 Jan 2015 22:00:02 +0000 Subject: [PATCH 3/9] test(ngOptions): should place non-grouped items in the list where they appear Closes #10531 --- test/ng/directive/ngOptionsSpec.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js index 41ec98b0690d..43b30a8cee8f 100644 --- a/test/ng/directive/ngOptionsSpec.js +++ b/test/ng/directive/ngOptionsSpec.js @@ -944,6 +944,36 @@ describe('ngOptions', function() { }); + it('should place non-grouped items in the list where they appear', function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.name group by item.group for item in values' + }); + + scope.$apply(function() { + scope.values = [{name: 'A'}, + {name: 'B', group: 'first'}, + {name: 'C', group: 'second'}, + {name: 'D'}, + {name: 'E', group: 'first'}, + {name: 'F'}, + {name: 'G'}, + {name: 'H', group: 'second'}]; + scope.selected = scope.values[0]; + }); + + var children = element.children(); + expect(children.length).toEqual(6); + + expect(nodeName_(children[0])).toEqual('option'); + expect(nodeName_(children[1])).toEqual('optgroup'); + expect(nodeName_(children[2])).toEqual('optgroup'); + expect(nodeName_(children[3])).toEqual('option'); + expect(nodeName_(children[4])).toEqual('option'); + expect(nodeName_(children[5])).toEqual('option'); + }); + + it('should bind to scope value and track/identify objects', function() { createSelect({ 'ng-model': 'selected', From 94ae2801806a7668448a618fcf87580558ee7c7e Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 10 Jan 2015 22:13:22 +0000 Subject: [PATCH 4/9] test(ngOptions): should not insert a blank option if one of the options maps to null Closes #7605 --- test/ng/directive/ngOptionsSpec.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js index 43b30a8cee8f..d1c185c3b8b9 100644 --- a/test/ng/directive/ngOptionsSpec.js +++ b/test/ng/directive/ngOptionsSpec.js @@ -1151,6 +1151,27 @@ describe('ngOptions', function() { }); + it('should not insert a blank option if one of the options maps to null', function() { + createSelect({ + 'ng-model': 'myColor', + 'ng-options': 'color.shade as color.name for color in colors' + }); + + scope.$apply(function() { + scope.colors = [ + {name:'nothing', shade:null}, + {name:'red', shade:'dark'} + ]; + scope.myColor = null; + }); + + expect(element.find('option').length).toEqual(2); + expect(element.find('option').eq(0)).toEqualOption(null); + expect(element.val()).not.toEqualUnknownValue(null); + expect(element.find('option').eq(0)).not.toEqualUnknownOption(null); + }); + + it('should insert a unknown option if bound to something not in the list', function() { createSingleSelect(); From 599e5b8d3745c77da5f6c4757ef8bc1eac01d72c Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sun, 11 Jan 2015 14:51:50 +0000 Subject: [PATCH 5/9] style(ngOptionsSpec): add extra newline for better formatting --- test/ng/directive/ngOptionsSpec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js index d1c185c3b8b9..f70ee516a07c 100644 --- a/test/ng/directive/ngOptionsSpec.js +++ b/test/ng/directive/ngOptionsSpec.js @@ -1025,6 +1025,7 @@ describe('ngOptions', function() { expect(element).toEqualSelectValue(scope.selected); }); + it('should update options in the DOM', function() { compile( '' From 2d0a0a51d914d9b310c5d9f908590cad6c3b49e6 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sun, 11 Jan 2015 15:59:56 +0000 Subject: [PATCH 6/9] fix(ngOptions): update model if selected option is removed Closes #7736 --- src/ng/directive/ngOptions.js | 11 ++++ test/ng/directive/ngOptionsSpec.js | 82 +++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/ng/directive/ngOptions.js b/src/ng/directive/ngOptions.js index a6a03e506469..4080876d3f8c 100644 --- a/src/ng/directive/ngOptions.js +++ b/src/ng/directive/ngOptions.js @@ -513,7 +513,10 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { function updateOptions() { + var previousValue = options && selectCtrl.readValue(); + options = ngOptions.getOptions(); + var groupMap = {}; var currentElement = selectElement[0].firstChild; @@ -586,6 +589,14 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { removeExcessElements(currentElement); ngModelCtrl.$render(); + + // Check to see if the value has changed due to the update to the options + if(!ngModelCtrl.$isEmpty(previousValue)) { + var nextValue = selectCtrl.readValue(); + if (!equals(previousValue, nextValue)) { + ngModelCtrl.$setViewValue(nextValue); + } + } } } diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js index f70ee516a07c..d94182c9fba0 100644 --- a/test/ng/directive/ngOptionsSpec.js +++ b/test/ng/directive/ngOptionsSpec.js @@ -22,15 +22,17 @@ describe('ngOptions', function() { this.addMatchers({ toEqualSelectValue: function(value, multiple) { var errors = []; + var actual = this.actual.val(); if (multiple) { value = value.map(function(val) { return hashKey(val); }); + actual = actual || []; } else { value = hashKey(value); } - if (!equals(this.actual.val(), value)) { - errors.push('Expected select value "' + this.actual.val() + '" to equal "' + value + '"'); + if (!equals(actual, value)) { + errors.push('Expected select value "' + actual + '" to equal "' + value + '"'); } this.message = function() { return errors.join('\n'); @@ -1279,6 +1281,82 @@ describe('ngOptions', function() { var option = element.find('option').eq(0); expect(option).toEqualUnknownOption(); }); + + + it('should update the model if the selected option is removed', function() { + scope.values = [{value: 0, label: 'zero'}, {value: 1, label: 'one'}]; + scope.selected = 1; + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'option.value as option.label for option in values' + }); + expect(element).toEqualSelectValue(1); + + // Check after initial option update + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element.val()).toEqualUnknownValue(); + expect(scope.selected).toEqual(null); + + // Check after model change + scope.$apply(function() { + scope.selected = 0; + }); + + expect(element).toEqualSelectValue(0); + + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element.val()).toEqualUnknownValue(); + expect(scope.selected).toEqual(null); + }); + + + it('should update the model if all the selected (multiple) options are removed', function() { + scope.values = [{value: 0, label: 'zero'}, {value: 1, label: 'one'}, {value: 2, label: 'two'}]; + scope.selected = [1, 2]; + createSelect({ + 'ng-model': 'selected', + 'multiple': true, + 'ng-options': 'option.value as option.label for option in values' + }); + + expect(element).toEqualSelectValue([1, 2], true); + + // Check after initial option update + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element).toEqualSelectValue([1], true); + expect(scope.selected).toEqual([1]); + + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element).toEqualSelectValue([], true); + expect(scope.selected).toEqual([]); + + // Check after model change + scope.$apply(function() { + scope.selected = [0]; + }); + + expect(element).toEqualSelectValue([0], true); + + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element).toEqualSelectValue([], true); + expect(scope.selected).toEqual([]); + }); + }); From de7c1781641985d0d590ba4df1398d01f806f8e2 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sun, 11 Jan 2015 15:59:56 +0000 Subject: [PATCH 7/9] refact(ngOptions): specialize readValue and writeValue based on multiple attribute --- src/ng/directive/ngOptions.js | 83 ++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/src/ng/directive/ngOptions.js b/src/ng/directive/ngOptions.js index 4080876d3f8c..9ecbc24975f7 100644 --- a/src/ng/directive/ngOptions.js +++ b/src/ng/directive/ngOptions.js @@ -366,67 +366,70 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { selectCtrl.writeValue = function writeNgOptionsValue(value) { - if (multiple) { + var option = options.getOptionFromViewValue(value); - options.items.forEach(function(option) { - option.element.selected = false; - }); + if (option) { + if (selectElement[0].value !== option.selectValue) { + removeUnknownOption(); + removeEmptyOption(); - if (value) { - value.forEach(function(item) { - var option = options.getOptionFromViewValue(item); - if (option) option.element.selected = true; - }); + selectElement[0].value = option.selectValue; + option.element.selected = true; + option.element.setAttribute('selected', 'selected'); } - } else { - var option = options.getOptionFromViewValue(value); - - if (option) { - if (selectElement[0].value !== option.selectValue) { - removeUnknownOption(); - removeEmptyOption(); - - selectElement[0].value = option.selectValue; - option.element.selected = true; - option.element.setAttribute('selected', 'selected'); - } + if (value === null || providedEmptyOption) { + removeUnknownOption(); + renderEmptyOption(); } else { - if (value === null || providedEmptyOption) { - removeUnknownOption(); - renderEmptyOption(); - } else { - removeEmptyOption(); - renderUnknownOption(); - } + removeEmptyOption(); + renderUnknownOption(); } } }; - selectCtrl.readValue = function readNgOptionsValue() { - if (multiple) { - - return selectElement.val().map(function(selectedKey) { - var option = options.selectValueMap[selectedKey]; - return option.viewValue; - }); - - } else { + var selectedOption = options.selectValueMap[selectElement.val()]; - var option = options.selectValueMap[selectElement.val()]; + if (selectedOption) { removeEmptyOption(); removeUnknownOption(); - return option ? option.viewValue : null; + return selectedOption.viewValue; } + return null; }; + // Update the controller methods for multiple selectable options if (multiple) { + ngModelCtrl.$isEmpty = function(value) { return !value || value.length === 0; }; + + + selectCtrl.writeValue = function writeNgOptionsMultiple(value) { + options.items.forEach(function(option) { + option.element.selected = false; + }); + + if (value) { + value.forEach(function(item) { + var option = options.getOptionFromViewValue(item); + if (option) option.element.selected = true; + }); + } + }; + + + selectCtrl.readValue = function readNgOptionsMultiple() { + var selectedValues = selectElement.val() || []; + return selectedValues.map(function(selectedKey) { + var option = options.selectValueMap[selectedKey]; + return option.viewValue; + }); + }; } @@ -591,7 +594,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { ngModelCtrl.$render(); // Check to see if the value has changed due to the update to the options - if(!ngModelCtrl.$isEmpty(previousValue)) { + if (!ngModelCtrl.$isEmpty(previousValue)) { var nextValue = selectCtrl.readValue(); if (!equals(previousValue, nextValue)) { ngModelCtrl.$setViewValue(nextValue); From 4725ddbfe66cc78fa83490968c6d2b3e288e3fad Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sun, 11 Jan 2015 22:16:06 +0000 Subject: [PATCH 8/9] fix(ngOptions): prevent infinite digest if track by expression is stable Closes #9464 --- src/ng/directive/ngOptions.js | 16 +++++++++++----- test/ng/directive/ngOptionsSpec.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/ng/directive/ngOptions.js b/src/ng/directive/ngOptions.js index 9ecbc24975f7..f1de17a33bac 100644 --- a/src/ng/directive/ngOptions.js +++ b/src/ng/directive/ngOptions.js @@ -227,8 +227,14 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { var valueFn = $parse(match[2] ? match[1] : valueName); var selectAsFn = selectAs && $parse(selectAs); var viewValueFn = selectAsFn || valueFn; - var trackByFn = trackBy ? $parse(trackBy) : - function getHashOfValue(value) { return hashKey(value); }; + var trackByFn = trackBy && $parse(trackBy); + + // Get the value by which we are going to track the option + // if we have a trackFn then use that (passing scope and locals) + // otherwise just hash the given viewValue + var getTrackByValue = trackBy ? + function(viewValue, locals) { return trackByFn(scope, locals); } : + function getHashOfValue(viewValue) { return hashKey(viewValue); }; var displayFn = $parse(match[2] || match[1]); var groupByFn = $parse(match[3] || ''); var valuesFn = $parse(match[7]); @@ -263,7 +269,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { Object.keys(values).forEach(function getWatchable(key) { var locals = getLocals(values[key], key); var label = displayFn(scope, locals); - var selectValue = viewValueFn(scope, locals); + var selectValue = getTrackByValue(values[key], locals); watchedArray.push(selectValue); watchedArray.push(label); }); @@ -288,7 +294,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { var value = optionValues[key]; var locals = getLocals(value, key); var viewValue = viewValueFn(scope, locals); - var selectValue = trackByFn(viewValue, locals); + var selectValue = getTrackByValue(viewValue, locals); var label = displayFn(scope, locals); var group = groupByFn(scope, locals); var optionItem = new Option(selectValue, viewValue, label, group); @@ -301,7 +307,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { items: optionItems, selectValueMap: selectValueMap, getOptionFromViewValue: function(value) { - return selectValueMap[trackByFn(value, getLocals(value))]; + return selectValueMap[getTrackByValue(value, getLocals(value))]; } }; } diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js index d94182c9fba0..867bebf81c87 100644 --- a/test/ng/directive/ngOptionsSpec.js +++ b/test/ng/directive/ngOptionsSpec.js @@ -706,6 +706,23 @@ describe('ngOptions', function() { browserTrigger(element, 'change'); expect(scope.selected).toEqual([scope.obj['1'], scope.obj['2']]); }); + + it('should prevent infinite digest if track by expression is stable', function() { + scope.makeOptions = function() { + var options = []; + for (var i = 0; i < 5; i++) { + options.push({ label: 'Value = ' + i, value: i }); + } + return options; + }; + scope.selected = { label: 'Value = 1', value: 1 }; + expect(function() { + createSelect({ + 'ng-model': 'selected', + 'ng-options': 'item.label for item in makeOptions() track by item.value' + }); + }).not.toThrow(); + }); }); From 70e501545237fe4add375141238887eede1bf512 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Mon, 12 Jan 2015 10:39:13 +0000 Subject: [PATCH 9/9] fix(ngOptions): support one-time binding on the option values Utilize the $watchDelegate on the watcher used to detect changes to the labels. Closes #10687 Closes #10694 --- src/ng/directive/ngOptions.js | 7 +++--- test/ng/directive/ngOptionsSpec.js | 36 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/ng/directive/ngOptions.js b/src/ng/directive/ngOptions.js index f1de17a33bac..f6ec5f227c0b 100644 --- a/src/ng/directive/ngOptions.js +++ b/src/ng/directive/ngOptions.js @@ -258,13 +258,12 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { } return { - getWatchables: function() { + getWatchables: $parse(valuesFn, function(values) { // Create a collection of things that we would like to watch (watchedArray) // so that they can all be watched using a single $watchCollection // that only runs the handler once if anything changes var watchedArray = []; - - var values = valuesFn(scope) || []; + values = values || []; Object.keys(values).forEach(function getWatchable(key) { var locals = getLocals(values[key], key); @@ -274,7 +273,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { watchedArray.push(label); }); return watchedArray; - }, + }), getOptions: function() { diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js index 867bebf81c87..21f890fc5406 100644 --- a/test/ng/directive/ngOptionsSpec.js +++ b/test/ng/directive/ngOptionsSpec.js @@ -497,6 +497,42 @@ describe('ngOptions', function() { }); + + it('should be possible to use one-time binding on the expression', function() { + createSelect({ + 'ng-model': 'someModel', + 'ng-options': 'o as o for o in ::arr' + }); + + var options; + + // Initially the options list is just the unknown option + options = element.find('option'); + expect(options.length).toEqual(1); + + // Now initialize the scope and the options should be updated + scope.$apply(function() { + scope.arr = ['a','b','c']; + }); + options = element.find('option'); + expect(options.length).toEqual(4); + expect(options.eq(0)).toEqualUnknownOption(); + expect(options.eq(1)).toEqualOption('a'); + expect(options.eq(2)).toEqualOption('b'); + expect(options.eq(3)).toEqualOption('c'); + + // Change the scope but the options should not change + scope.arr = ['w', 'x', 'y', 'z']; + scope.$digest(); + options = element.find('option'); + expect(options.length).toEqual(4); + expect(options.eq(0)).toEqualUnknownOption(); + expect(options.eq(1)).toEqualOption('a'); + expect(options.eq(2)).toEqualOption('b'); + expect(options.eq(3)).toEqualOption('c'); + }); + + describe('selectAs expression', function() { beforeEach(function() { scope.arr = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}];