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..f6ec5f227c0b --- /dev/null +++ b/src/ng/directive/ngOptions.js @@ -0,0 +1,612 @@ +'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 `<option>` + * elements for the `<select>` element using the array or object obtained by evaluating the + * `ngOptions` comprehension expression. + * + * In many cases, `ngRepeat` can be used on `<option>` elements instead of `ngOptions` to achieve a + * similar result. However, `ngOptions` provides some benefits such as reducing memory and + * increasing speed by not creating a new scope for each repeated instance, as well as providing + * more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the + * comprehension expression. `ngOptions` should be used when the `<select>` model needs to be bound + * to a non-string value. This is because an option element can only be bound to string values at + * present. + * + * When an item in the `<select>` menu is selected, the array element or object property + * represented by the selected option will be bound to the model identified by the `ngModel` + * directive. + * + * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can + * be nested into the `<select>` element. This element will then represent the `null` or "not selected" + * option. See example below for demonstration. + * + * <div class="alert alert-warning"> + * **Note:** `ngModel` compares by reference, not value. This is important when binding to an + * array of objects. See an example [in this jsfiddle](http://jsfiddle.net/qWzTb/). + * </div> + * + * ## `select` **`as`** + * + * Using `select` **`as`** will bind the result of the `select` expression to the model, but + * the value of the `<select>` and `<option>` html elements will be either the index (for array data sources) + * or property name (for object data sources) of the value within the collection. If a **`track by`** expression + * is used, the result of that expression will be set as the value of the `option` and `select` elements. + * + * + * ### `select` **`as`** and **`track by`** + * + * <div class="alert alert-warning"> + * Do not use `select` **`as`** and **`track by`** in the same expression. They are not designed to work together. + * </div> + * + * Consider the following example: + * + * ```html + * <select ng-options="item.subItem as item.label for item in values track by item.id" ng-model="selected"> + * ``` + * + * ```js + * $scope.values = [{ + * id: 1, + * label: 'aLabel', + * subItem: { name: 'aSubItem' } + * }, { + * id: 2, + * label: 'bLabel', + * subItem: { name: 'bSubItem' } + * }]; + * + * $scope.selected = { name: 'aSubItem' }; + * ``` + * + * With the purpose of preserving the selection, the **`track by`** expression is always applied to the element + * of the data source (to `item` in this example). To calculate whether an element is selected, we do the + * following: + * + * 1. Apply **`track by`** to the elements in the array. In the example: `[1, 2]` + * 2. Apply **`track by`** to the already selected value in `ngModel`. + * In the example: this is not possible as **`track by`** refers to `item.id`, but the selected + * value from `ngModel` is `{name: 'aSubItem'}`, so the **`track by`** expression is applied to + * a wrong object, the selected element can't be found, `<select>` is always reset to the "not + * selected" option. + * + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required The control is considered valid only if value is entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {comprehension_expression=} ngOptions in one of the following forms: + * + * * for array data sources: + * * `label` **`for`** `value` **`in`** `array` + * * `select` **`as`** `label` **`for`** `value` **`in`** `array` + * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` + * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` **`track by`** `trackexpr` + * * `label` **`for`** `value` **`in`** `array` | orderBy:`orderexpr` **`track by`** `trackexpr` + * (for including a filter with `track by`) + * * for object data sources: + * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` + * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`group by`** `group` + * **`for` `(`**`key`**`,`** `value`**`) in`** `object` + * + * Where: + * + * * `array` / `object`: an expression which evaluates to an array / object to iterate over. + * * `value`: local variable which will refer to each item in the `array` or each property value + * of `object` during iteration. + * * `key`: local variable which will refer to a property name in `object` during iteration. + * * `label`: The result of this expression will be the label for `<option>` element. The + * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). + * * `select`: The result of this expression will be bound to the model of the parent `<select>` + * element. If not specified, `select` expression will default to `value`. + * * `group`: The result of this expression will be used to group options using the `<optgroup>` + * 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 + <example module="selectExample"> + <file name="index.html"> + <script> + angular.module('selectExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.colors = [ + {name:'black', shade:'dark'}, + {name:'white', shade:'light'}, + {name:'red', shade:'dark'}, + {name:'blue', shade:'dark'}, + {name:'yellow', shade:'light'} + ]; + $scope.myColor = $scope.colors[2]; // red + }]); + </script> + <div ng-controller="ExampleController"> + <ul> + <li ng-repeat="color in colors"> + Name: <input ng-model="color.name"> + [<a href ng-click="colors.splice($index, 1)">X</a>] + </li> + <li> + [<a href ng-click="colors.push({})">add</a>] + </li> + </ul> + <hr/> + Color (null not allowed): + <select ng-model="myColor" ng-options="color.name for color in colors"></select><br> + + Color (null allowed): + <span class="nullable"> + <select ng-model="myColor" ng-options="color.name for color in colors"> + <option value="">-- choose color --</option> + </select> + </span><br/> + + Color grouped by shade: + <select ng-model="myColor" ng-options="color.name group by color.shade for color in colors"> + </select><br/> + + + Select <a href ng-click="myColor = { name:'not in list', shade: 'other' }">bogus</a>.<br> + <hr/> + Currently selected: {{ {selected_color:myColor} }} + <div style="border:solid 1px black; height:20px" + ng-style="{'background-color':myColor.name}"> + </div> + </div> + </file> + <file name="protractor.js" type="protractor"> + 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'); + }); + </file> + </example> + */ + +// 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); + + // 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]); + + 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: $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 = []; + values = values || []; + + Object.keys(values).forEach(function getWatchable(key) { + var locals = getLocals(values[key], key); + var label = displayFn(scope, locals); + var selectValue = getTrackByValue(values[key], 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 = getTrackByValue(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[getTrackByValue(value, getLocals(value))]; + } + }; + } + }; + } + + + // we can't just jqLite('<option>') since jqLite is not smart enough + // to create it in <select> and IE barfs otherwise. + var optionTemplate = document.createElement('option'), + optGroupTemplate = document.createElement('optgroup'); + + return { + restrict: 'A', + terminal: true, + require: ['select', '?ngModel'], + link: function(scope, selectElement, attr, ctrls) { + + // if ngModel is not defined, we don't need to do anything + var ngModelCtrl = ctrls[1]; + if (!ngModelCtrl) return; + + var selectCtrl = ctrls[0]; + var multiple = attr.multiple; + + var emptyOption = selectCtrl.emptyOption; + var providedEmptyOption = !!emptyOption; + + var unknownOption = jqLite(optionTemplate.cloneNode(false)); + unknownOption.val('?'); + + var options; + var ngOptions = parseOptionsExpression(attr.ngOptions, selectElement, scope); + + + var renderEmptyOption = function() { + if (!providedEmptyOption) { + selectElement.prepend(emptyOption); + } + selectElement.val(''); + emptyOption.prop('selected', true); // needed for IE + emptyOption.attr('selected', true); + }; + + var removeEmptyOption = function() { + if (!providedEmptyOption) { + emptyOption.remove(); + } + }; + + + var renderUnknownOption = function() { + selectElement.prepend(unknownOption); + selectElement.val('?'); + unknownOption.prop('selected', true); // needed for IE + unknownOption.attr('selected', true); + }; + + var removeUnknownOption = function() { + unknownOption.remove(); + }; + + + selectCtrl.writeValue = function writeNgOptionsValue(value) { + 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'); + } + } else { + if (value === null || providedEmptyOption) { + removeUnknownOption(); + renderEmptyOption(); + } else { + removeEmptyOption(); + renderUnknownOption(); + } + } + }; + + selectCtrl.readValue = function readNgOptionsValue() { + + var selectedOption = options.selectValueMap[selectElement.val()]; + + if (selectedOption) { + removeEmptyOption(); + removeUnknownOption(); + 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; + }); + }; + } + + + if (providedEmptyOption) { + + // we need to remove it before calling selectElement.empty() because otherwise IE will + // remove the label from the element. wtf? + emptyOption.remove(); + + // compile the element since there might be bindings in it + $compile(emptyOption)(scope); + + // remove the class, which is added automatically because we recompile the element and it + // becomes the compilation root + emptyOption.removeClass('ng-scope'); + } else { + emptyOption = jqLite(optionTemplate.cloneNode(false)); + } + + // We need to do this here to ensure that the options object is defined + // when we first hit it in writeNgOptionsValue + updateOptions(); + + // We will re-render the option elements if the option values or labels change + scope.$watchCollection(ngOptions.getWatchables, updateOptions); + + // ------------------------------------------------------------------ // + + + function updateOptionElement(option, element) { + option.element = element; + if (option.value !== element.value) element.value = option.selectValue; + if (option.label !== element.label) { + element.label = option.label; + element.textContent = option.label; + } + } + + function addOrReuseElement(parent, current, type, templateElement) { + var element; + // Check whether we can reuse the next element + if (current && lowercase(current.nodeName) === type) { + // The next element is the right type so reuse it + element = current; + } else { + // The next element is not the right type so create a new one + element = templateElement.cloneNode(false); + if (!current) { + // There are no more elements so just append it to the select + parent.appendChild(element); + } else { + // The next element is not a group so insert the new one + parent.insertBefore(element, current); + } + } + return element; + } + + + function removeExcessElements(current) { + var next; + while (current) { + next = current.nextSibling; + jqLiteRemove(current); + current = next; + } + } + + + function skipEmptyAndUnknownOptions(current) { + var emptyOption_ = emptyOption && emptyOption[0]; + var unknownOption_ = unknownOption && unknownOption[0]; + + if (emptyOption_ || unknownOption_) { + while (current && + (current === emptyOption_ || + current === unknownOption_)) { + current = current.nextSibling; + } + } + return current; + } + + + function updateOptions() { + + var previousValue = options && selectCtrl.readValue(); + + options = ngOptions.getOptions(); + + var groupMap = {}; + var currentElement = selectElement[0].firstChild; + + // Ensure that the empty option is always there if it was explicitly provided + if (providedEmptyOption) { + selectElement.prepend(emptyOption); + } + + currentElement = skipEmptyAndUnknownOptions(currentElement); + + options.items.forEach(function updateOption(option) { + var group; + var groupElement; + var optionElement; + + if (option.group) { + + // This option is to live in a group + // See if we have already created this group + group = groupMap[option.group]; + + if (!group) { + + // We have not already created this group + groupElement = addOrReuseElement(selectElement[0], + currentElement, + 'optgroup', + optGroupTemplate); + // Move to the next element + currentElement = groupElement.nextSibling; + + // Update the label on the group element + groupElement.label = option.group; + + // Store it for use later + group = groupMap[option.group] = { + groupElement: groupElement, + currentOptionElement: groupElement.firstChild + }; + + } + + // So now we have a group for this option we add the option to the group + optionElement = addOrReuseElement(group.groupElement, + group.currentOptionElement, + 'option', + optionTemplate); + updateOptionElement(option, optionElement); + // Move to the next element + group.currentOptionElement = optionElement.nextSibling; + + } else { + + // This option is not in a group + optionElement = addOrReuseElement(selectElement[0], + currentElement, + 'option', + optionTemplate); + updateOptionElement(option, optionElement); + // Move to the next element + currentElement = optionElement.nextSibling; + } + }); + + + // Now remove all excess options and group + Object.keys(groupMap).forEach(function(key) { + removeExcessElements(groupMap[key].currentOptionElement); + }); + 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/src/ng/directive/select.js b/src/ng/directive/select.js index 071a90ab1022..61079ec6050d 100644 --- a/src/ng/directive/select.js +++ b/src/ng/directive/select.js @@ -1,6 +1,111 @@ 'use strict'; -var ngOptionsMinErr = minErr('ngOptions'); +var noopNgModelController = { $setViewValue: noop, $render: noop }; + +/** + * @ngdoc type + * @name select.SelectController + * @description + * The controller for the `<select>` directive. This provides support for reading + * and writing the selected value(s) of the control and also coordinates dynamically + * added `<option>` elements, perhaps by an `ngRepeat` directive. + */ +var SelectController = + ['$element', '$scope', '$attrs', function($element, $scope, $attrs) { + + var self = this, + optionsMap = new HashMap(); + + // If the ngModel doesn't get provided then provide a dummy noop version to prevent errors + self.ngModelCtrl = noopNgModelController; + + // The "unknown" option is one that is prepended to the list if the viewValue + // does not match any of the options. When it is rendered the value of the unknown + // option is '? XXX ?' where XXX is the hashKey of the value that is not known. + // + // We can't just jqLite('<option>') since jqLite is not smart enough + // to create it in <select> 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,12 +114,6 @@ var ngOptionsMinErr = minErr('ngOptions'); * @description * HTML `SELECT` element with angular data-binding. * - * # `ngOptions` - * - * The `ngOptions` attribute can be used to dynamically generate a list of `<option>` - * elements for the `<select>` element using the array or object obtained by evaluating the - * `ngOptions` comprehension expression. - * * In many cases, `ngRepeat` can be used on `<option>` elements instead of `ngOptions` to achieve a * similar result. However, `ngOptions` provides some benefits such as reducing memory and * increasing speed by not creating a new scope for each repeated instance, as well as providing @@ -27,6 +126,9 @@ var ngOptionsMinErr = minErr('ngOptions'); * represented by the selected option will be bound to the model identified by the `ngModel` * directive. * + * If the viewValue contains a value that doesn't match any of the options then the control + * will automatically add an "unknown" option, which it then removes when this is resolved. + * * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can * be nested into the `<select>` element. This element will then represent the `null` or "not selected" * option. See example below for demonstration. @@ -36,307 +138,61 @@ var ngOptionsMinErr = minErr('ngOptions'); * array of objects. See an example [in this jsfiddle](http://jsfiddle.net/qWzTb/). * </div> * - * ## `select` **`as`** - * - * Using `select` **`as`** will bind the result of the `select` expression to the model, but - * the value of the `<select>` and `<option>` html elements will be either the index (for array data sources) - * or property name (for object data sources) of the value within the collection. If a **`track by`** expression - * is used, the result of that expression will be set as the value of the `option` and `select` elements. - * - * - * ### `select` **`as`** and **`track by`** - * - * <div class="alert alert-warning"> - * Do not use `select` **`as`** and **`track by`** in the same expression. They are not designed to work together. - * </div> - * - * Consider the following example: - * - * ```html - * <select ng-options="item.subItem as item.label for item in values track by item.id" ng-model="selected"> - * ``` - * - * ```js - * $scope.values = [{ - * id: 1, - * label: 'aLabel', - * subItem: { name: 'aSubItem' } - * }, { - * id: 2, - * label: 'bLabel', - * subItem: { name: 'bSubItem' } - * }]; - * - * $scope.selected = { name: 'aSubItem' }; - * ``` - * - * With the purpose of preserving the selection, the **`track by`** expression is always applied to the element - * of the data source (to `item` in this example). To calculate whether an element is selected, we do the - * following: - * - * 1. Apply **`track by`** to the elements in the array. In the example: `[1, 2]` - * 2. Apply **`track by`** to the already selected value in `ngModel`. - * In the example: this is not possible as **`track by`** refers to `item.id`, but the selected - * value from `ngModel` is `{name: 'aSubItem'}`, so the **`track by`** expression is applied to - * a wrong object, the selected element can't be found, `<select>` is always reset to the "not - * selected" option. - * - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required The control is considered valid only if value is entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {comprehension_expression=} ngOptions in one of the following forms: - * - * * for array data sources: - * * `label` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`for`** `value` **`in`** `array` - * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` - * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` **`track by`** `trackexpr` - * * `label` **`for`** `value` **`in`** `array` | orderBy:`orderexpr` **`track by`** `trackexpr` - * (for including a filter with `track by`) - * * for object data sources: - * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`group by`** `group` - * **`for` `(`**`key`**`,`** `value`**`) in`** `object` - * - * Where: - * - * * `array` / `object`: an expression which evaluates to an array / object to iterate over. - * * `value`: local variable which will refer to each item in the `array` or each property value - * of `object` during iteration. - * * `key`: local variable which will refer to a property name in `object` during iteration. - * * `label`: The result of this expression will be the label for `<option>` element. The - * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). - * * `select`: The result of this expression will be bound to the model of the parent `<select>` - * element. If not specified, `select` expression will default to `value`. - * * `group`: The result of this expression will be used to group options using the `<optgroup>` - * 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 - <example module="selectExample"> - <file name="index.html"> - <script> - angular.module('selectExample', []) - .controller('ExampleController', ['$scope', function($scope) { - $scope.colors = [ - {name:'black', shade:'dark'}, - {name:'white', shade:'light'}, - {name:'red', shade:'dark'}, - {name:'blue', shade:'dark'}, - {name:'yellow', shade:'light'} - ]; - $scope.myColor = $scope.colors[2]; // red - }]); - </script> - <div ng-controller="ExampleController"> - <ul> - <li ng-repeat="color in colors"> - Name: <input ng-model="color.name"> - [<a href ng-click="colors.splice($index, 1)">X</a>] - </li> - <li> - [<a href ng-click="colors.push({})">add</a>] - </li> - </ul> - <hr/> - Color (null not allowed): - <select ng-model="myColor" ng-options="color.name for color in colors"></select><br> - - Color (null allowed): - <span class="nullable"> - <select ng-model="myColor" ng-options="color.name for color in colors"> - <option value="">-- choose color --</option> - </select> - </span><br/> - - Color grouped by shade: - <select ng-model="myColor" ng-options="color.name group by color.shade for color in colors"> - </select><br/> - - - Select <a href ng-click="myColor = { name:'not in list', shade: 'other' }">bogus</a>.<br> - <hr/> - Currently selected: {{ {selected_color:myColor} }} - <div style="border:solid 1px black; height:20px" - ng-style="{'background-color':myColor.name}"> - </div> - </div> - </file> - <file name="protractor.js" type="protractor"> - 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'); - }); - </file> - </example> */ - -var ngOptionsDirective = valueFn({ - restrict: 'A', - terminal: true -}); - -// jshint maxlen: false -var selectDirective = ['$compile', '$parse', function($compile, $parse) { - //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]+?))?$/, - nullModelCtrl = {$setViewValue: noop}; -// jshint maxlen: 100 +var selectDirective = function() { + var lastView; return { restrict: 'E', require: ['select', '?ngModel'], - controller: ['$element', '$scope', '$attrs', function($element, $scope, $attrs) { - var self = this, - optionsMap = {}, - ngModelCtrl = nullModelCtrl, - nullOption, - unknownOption; - - - self.databound = $attrs.ngModel; - - - self.init = function(ngModelCtrl_, nullOption_, unknownOption_) { - ngModelCtrl = ngModelCtrl_; - nullOption = nullOption_; - unknownOption = unknownOption_; - }; - - - self.addOption = function(value, element) { - assertNotHasOwnProperty(value, '"option value"'); - optionsMap[value] = true; - - if (ngModelCtrl.$viewValue == value) { - $element.val(value); - if (unknownOption.parent()) unknownOption.remove(); - } - // Workaround for https://code.google.com/p/chromium/issues/detail?id=381459 - // Adding an <option selected="selected"> element to a <select required="required"> should - // automatically select the new element - if (element && element[0].hasAttribute('selected')) { - element[0].selected = true; - } - }; - - - self.removeOption = function(value) { - if (this.hasOption(value)) { - delete optionsMap[value]; - if (ngModelCtrl.$viewValue === value) { - this.renderUnknownOption(value); - } - } - }; + controller: SelectController, + link: function(scope, element, attr, ctrls) { + // if ngModel is not defined, we don't need to do anything + var ngModelCtrl = ctrls[1]; + if (!ngModelCtrl) return; - self.renderUnknownOption = function(val) { - var unknownVal = '? ' + hashKey(val) + ' ?'; - unknownOption.val(unknownVal); - $element.prepend(unknownOption); - $element.val(unknownVal); - unknownOption.prop('selected', true); // needed for IE - }; + var selectCtrl = ctrls[0]; + selectCtrl.ngModelCtrl = ngModelCtrl; - self.hasOption = function(value) { - return optionsMap.hasOwnProperty(value); + // We delegate rendering to the `writeValue` method, which can be changed + // if the select can have multiple selected values or if the options are being + // generated by `ngOptions` + ngModelCtrl.$render = function() { + selectCtrl.writeValue(ngModelCtrl.$viewValue); }; - $scope.$on('$destroy', function() { - // disable unknown option so that we don't do work when the whole select is being destroyed - self.renderUnknownOption = noop; + // When the selected item(s) changes we delegate getting the value of the select control + // to the `readValue` method, which can be changed if the select can have multiple + // selected values or if the options are being generated by `ngOptions` + element.on('change', function() { + scope.$apply(function() { + ngModelCtrl.$setViewValue(selectCtrl.readValue()); + }); }); - }], - link: function(scope, element, attr, ctrls) { - // if ngModel is not defined, we don't need to do anything - if (!ctrls[1]) return; - - var selectCtrl = ctrls[0], - ngModelCtrl = ctrls[1], - multiple = attr.multiple, - optionsExp = attr.ngOptions, - nullOption = false, // if false, user will not be able to select it (used by ngOptions) - emptyOption, - renderScheduled = false, - // we can't just jqLite('<option>') since jqLite is not smart enough - // to create it in <select> and IE barfs otherwise. - optionTemplate = jqLite(document.createElement('option')), - optGroupTemplate =jqLite(document.createElement('optgroup')), - unknownOption = optionTemplate.clone(); - - // find "null" option - for (var i = 0, children = element.children(), ii = children.length; i < ii; i++) { - if (children[i].value === '') { - emptyOption = nullOption = children.eq(i); - break; - } - } - - selectCtrl.init(ngModelCtrl, nullOption, unknownOption); - - // required validator - if (multiple) { - ngModelCtrl.$isEmpty = function(value) { - return !value || value.length === 0; - }; - } - - if (optionsExp) setupAsOptions(scope, element, ngModelCtrl); - else if (multiple) setupAsMultiple(scope, element, ngModelCtrl); - else setupAsSingle(scope, element, ngModelCtrl, selectCtrl); - - - //////////////////////////// - - - - function setupAsSingle(scope, selectElement, ngModelCtrl, selectCtrl) { - ngModelCtrl.$render = function() { - var viewValue = ngModelCtrl.$viewValue; - - if (selectCtrl.hasOption(viewValue)) { - if (unknownOption.parent()) unknownOption.remove(); - selectElement.val(viewValue); - if (viewValue === '') emptyOption.prop('selected', true); // to make IE9 happy - } else { - if (isUndefined(viewValue) && emptyOption) { - selectElement.val(''); - } else { - selectCtrl.renderUnknownOption(viewValue); + // If the select allows multiple values then we need to modify how we read and write + // values from and to the control; also what it means for the value to be empty and + // we have to add an extra watch since ngModel doesn't work well with arrays - it + // doesn't trigger rendering if only an item in the array changes. + if (attr.multiple) { + + // Read value now needs to check each option to see if it is selected + selectCtrl.readValue = function readMultipleValue() { + var array = []; + forEach(element.find('option'), function(option) { + if (option.selected) { + array.push(option.value); } - } - }; - - selectElement.on('change', function() { - scope.$apply(function() { - if (unknownOption.parent()) unknownOption.remove(); - ngModelCtrl.$setViewValue(selectElement.val()); }); - }); - } + return array; + }; - function setupAsMultiple(scope, selectElement, ctrl) { - var lastView; - ctrl.$render = function() { - var items = new HashMap(ctrl.$viewValue); - forEach(selectElement.find('option'), function(option) { + // Write value now needs to set the selected property of each matching option + selectCtrl.writeValue = function writeMultipleValue(value) { + var items = new HashMap(value); + forEach(element.find('option'), function(option) { option.selected = isDefined(items.get(option.value)); }); }; @@ -344,400 +200,45 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) { // we have to do it on each watch since ngModel watches reference, but // we need to work of an array, so we need to see if anything was inserted/removed scope.$watch(function selectMultipleWatch() { - if (!equals(lastView, ctrl.$viewValue)) { - lastView = shallowCopy(ctrl.$viewValue); - ctrl.$render(); + if (!equals(lastView, ngModelCtrl.$viewValue)) { + lastView = shallowCopy(ngModelCtrl.$viewValue); + ngModelCtrl.$render(); } }); - selectElement.on('change', function() { - scope.$apply(function() { - var array = []; - forEach(selectElement.find('option'), function(option) { - if (option.selected) { - array.push(option.value); - } - }); - ctrl.$setViewValue(array); - }); - }); - } - - function setupAsOptions(scope, selectElement, ctrl) { - var match; - - if (!(match = optionsExp.match(NG_OPTIONS_REGEXP))) { - throw ngOptionsMinErr('iexp', - "Expected expression in form of " + - "'_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + - " but got '{0}'. Element: {1}", - optionsExp, startingTag(selectElement)); - } - - var displayFn = $parse(match[2] || match[1]), - valueName = match[4] || match[6], - selectAs = / as /.test(match[0]) && match[1], - selectAsFn = selectAs ? $parse(selectAs) : null, - keyName = match[5], - groupByFn = $parse(match[3] || ''), - valueFn = $parse(match[2] ? match[1] : valueName), - valuesFn = $parse(match[7]), - track = match[8], - trackFn = track ? $parse(match[8]) : null, - trackKeysCache = {}, - // This is an array of array of existing option groups in DOM. - // We try to reuse these if possible - // - optionGroupsCache[0] is the options with no option group - // - optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element - optionGroupsCache = [[{element: selectElement, label:''}]], - //re-usable object to represent option's locals - locals = {}; - - if (nullOption) { - // compile the element since there might be bindings in it - $compile(nullOption)(scope); - - // remove the class, which is added automatically because we recompile the element and it - // becomes the compilation root - nullOption.removeClass('ng-scope'); - - // we need to remove it before calling selectElement.empty() because otherwise IE will - // remove the label from the element. wtf? - nullOption.remove(); - } - - // clear contents, we'll add what's needed based on the model - selectElement.empty(); - - selectElement.on('change', selectionChanged); - - ctrl.$render = render; - - scope.$watchCollection(valuesFn, scheduleRendering); - scope.$watchCollection(getLabels, scheduleRendering); - - if (multiple) { - scope.$watchCollection(function() { return ctrl.$modelValue; }, scheduleRendering); - } - - // ------------------------------------------------------------------ // - - function callExpression(exprFn, key, value) { - locals[valueName] = value; - if (keyName) locals[keyName] = key; - return exprFn(scope, locals); - } - - function selectionChanged() { - scope.$apply(function() { - var collection = valuesFn(scope) || []; - var viewValue; - if (multiple) { - viewValue = []; - forEach(selectElement.val(), function(selectedKey) { - selectedKey = trackFn ? trackKeysCache[selectedKey] : selectedKey; - viewValue.push(getViewValue(selectedKey, collection[selectedKey])); - }); - } else { - var selectedKey = trackFn ? trackKeysCache[selectElement.val()] : selectElement.val(); - viewValue = getViewValue(selectedKey, collection[selectedKey]); - } - ctrl.$setViewValue(viewValue); - render(); - }); - } - - function getViewValue(key, value) { - if (key === '?') { - return undefined; - } else if (key === '') { - return null; - } else { - var viewValueFn = selectAsFn ? selectAsFn : valueFn; - return callExpression(viewValueFn, key, value); - } - } - - function getLabels() { - var values = valuesFn(scope); - var toDisplay; - if (values && isArray(values)) { - toDisplay = new Array(values.length); - for (var i = 0, ii = values.length; i < ii; i++) { - toDisplay[i] = callExpression(displayFn, i, values[i]); - } - return toDisplay; - } else if (values) { - // TODO: Add a test for this case - toDisplay = {}; - for (var prop in values) { - if (values.hasOwnProperty(prop)) { - toDisplay[prop] = callExpression(displayFn, prop, values[prop]); - } - } - } - return toDisplay; - } - - function createIsSelectedFn(viewValue) { - var selectedSet; - if (multiple) { - if (trackFn && isArray(viewValue)) { - - selectedSet = new HashMap([]); - for (var trackIndex = 0; trackIndex < viewValue.length; trackIndex++) { - // tracking by key - selectedSet.put(callExpression(trackFn, null, viewValue[trackIndex]), true); - } - } else { - selectedSet = new HashMap(viewValue); - } - } else if (trackFn) { - viewValue = callExpression(trackFn, null, viewValue); - } - - return function isSelected(key, value) { - var compareValueFn; - if (trackFn) { - compareValueFn = trackFn; - } else if (selectAsFn) { - compareValueFn = selectAsFn; - } else { - compareValueFn = valueFn; - } - - if (multiple) { - return isDefined(selectedSet.remove(callExpression(compareValueFn, key, value))); - } else { - return viewValue === callExpression(compareValueFn, key, value); - } - }; - } - - function scheduleRendering() { - if (!renderScheduled) { - scope.$$postDigest(render); - renderScheduled = true; - } - } - - /** - * A new labelMap is created with each render. - * This function is called for each existing option with added=false, - * and each new option with added=true. - * - Labels that are passed to this method twice, - * (once with added=true and once with added=false) will end up with a value of 0, and - * will cause no change to happen to the corresponding option. - * - Labels that are passed to this method only once with added=false will end up with a - * value of -1 and will eventually be passed to selectCtrl.removeOption() - * - Labels that are passed to this method only once with added=true will end up with a - * value of 1 and will eventually be passed to selectCtrl.addOption() - */ - function updateLabelMap(labelMap, label, added) { - labelMap[label] = labelMap[label] || 0; - labelMap[label] += (added ? 1 : -1); - } - - function render() { - renderScheduled = false; - - // Temporary location for the option groups before we render them - var optionGroups = {'':[]}, - optionGroupNames = [''], - optionGroupName, - optionGroup, - option, - existingParent, existingOptions, existingOption, - viewValue = ctrl.$viewValue, - values = valuesFn(scope) || [], - keys = keyName ? sortedKeys(values) : values, - key, - value, - groupLength, length, - groupIndex, index, - labelMap = {}, - selected, - isSelected = createIsSelectedFn(viewValue), - anySelected = false, - lastElement, - element, - label, - optionId; - - trackKeysCache = {}; - - // We now build up the list of options we need (we merge later) - for (index = 0; length = keys.length, index < length; index++) { - key = index; - if (keyName) { - key = keys[index]; - if (key.charAt(0) === '$') continue; - } - value = values[key]; - - optionGroupName = callExpression(groupByFn, key, value) || ''; - if (!(optionGroup = optionGroups[optionGroupName])) { - optionGroup = optionGroups[optionGroupName] = []; - optionGroupNames.push(optionGroupName); - } - - selected = isSelected(key, value); - anySelected = anySelected || selected; - - label = callExpression(displayFn, key, value); // what will be seen by the user - - // doing displayFn(scope, locals) || '' overwrites zero values - label = isDefined(label) ? label : ''; - optionId = trackFn ? trackFn(scope, locals) : (keyName ? keys[index] : index); - if (trackFn) { - trackKeysCache[optionId] = key; - } - - optionGroup.push({ - // either the index into array or key from object - id: optionId, - label: label, - selected: selected // determine if we should be selected - }); - } - if (!multiple) { - if (nullOption || viewValue === null) { - // insert null option if we have a placeholder, or the model is null - optionGroups[''].unshift({id:'', label:'', selected:!anySelected}); - } else if (!anySelected) { - // option could not be found, we have to insert the undefined item - optionGroups[''].unshift({id:'?', label:'', selected:true}); - } - } - - // Now we need to update the list of DOM nodes to match the optionGroups we computed above - for (groupIndex = 0, groupLength = optionGroupNames.length; - groupIndex < groupLength; - groupIndex++) { - // current option group name or '' if no group - optionGroupName = optionGroupNames[groupIndex]; - - // list of options for that group. (first item has the parent) - optionGroup = optionGroups[optionGroupName]; - - if (optionGroupsCache.length <= groupIndex) { - // we need to grow the optionGroups - existingParent = { - element: optGroupTemplate.clone().attr('label', optionGroupName), - label: optionGroup.label - }; - existingOptions = [existingParent]; - optionGroupsCache.push(existingOptions); - selectElement.append(existingParent.element); - } else { - existingOptions = optionGroupsCache[groupIndex]; - existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element - - // update the OPTGROUP label if not the same. - if (existingParent.label != optionGroupName) { - existingParent.element.attr('label', existingParent.label = optionGroupName); - } - } + // If we are a multiple select then value is now a collection + // so the meaning of $isEmpty changes + ngModelCtrl.$isEmpty = function(value) { + return !value || value.length === 0; + }; - lastElement = null; // start at the beginning - for (index = 0, length = optionGroup.length; index < length; index++) { - option = optionGroup[index]; - if ((existingOption = existingOptions[index + 1])) { - // reuse elements - lastElement = existingOption.element; - if (existingOption.label !== option.label) { - updateLabelMap(labelMap, existingOption.label, false); - updateLabelMap(labelMap, option.label, true); - lastElement.text(existingOption.label = option.label); - lastElement.prop('label', existingOption.label); - } - if (existingOption.id !== option.id) { - lastElement.val(existingOption.id = option.id); - } - // lastElement.prop('selected') provided by jQuery has side-effects - if (lastElement[0].selected !== option.selected) { - lastElement.prop('selected', (existingOption.selected = option.selected)); - if (msie) { - // See #7692 - // The selected item wouldn't visually update on IE without this. - // Tested on Win7: IE9, IE10 and IE11. Future IEs should be tested as well - lastElement.prop('selected', existingOption.selected); - } - } - } else { - // grow elements - - // if it's a null option - if (option.id === '' && nullOption) { - // put back the pre-compiled element - element = nullOption; - } else { - // jQuery(v1.4.2) Bug: We should be able to chain the method calls, but - // in this version of jQuery on some browser the .text() returns a string - // rather then the element. - (element = optionTemplate.clone()) - .val(option.id) - .prop('selected', option.selected) - .attr('selected', option.selected) - .prop('label', option.label) - .text(option.label); - } - - existingOptions.push(existingOption = { - element: element, - label: option.label, - id: option.id, - selected: option.selected - }); - updateLabelMap(labelMap, option.label, true); - if (lastElement) { - lastElement.after(element); - } else { - existingParent.element.append(element); - } - lastElement = element; - } - } - // remove any excessive OPTIONs in a group - index++; // increment since the existingOptions[0] is parent element not OPTION - while (existingOptions.length > index) { - option = existingOptions.pop(); - updateLabelMap(labelMap, option.label, false); - option.element.remove(); - } - } - // remove any excessive OPTGROUPs from select - while (optionGroupsCache.length > groupIndex) { - // remove all the labels in the option group - optionGroup = optionGroupsCache.pop(); - for (index = 1; index < optionGroup.length; ++index) { - updateLabelMap(labelMap, optionGroup[index].label, false); - } - optionGroup[0].element.remove(); - } - forEach(labelMap, function(count, label) { - if (count > 0) { - selectCtrl.addOption(label); - } else if (count < 0) { - selectCtrl.removeOption(label); - } - }); - } } } }; -}]; +}; + +// The option directive is purely designed to communicate the existence (or lack of) +// of dynamically created (and destroyed) option elements to their containing select +// directive via its controller. var optionDirective = ['$interpolate', function($interpolate) { - var nullSelectCtrl = { - addOption: noop, - removeOption: noop - }; + + function chromeHack(optionElement) { + // Workaround for https://code.google.com/p/chromium/issues/detail?id=381459 + // Adding an <option selected="selected"> element to a <select required="required"> should + // automatically select the new element + if (optionElement[0].hasAttribute('selected')) { + optionElement[0].selected = true; + } + } return { restrict: 'E', priority: 100, compile: function(element, attr) { + + // If the value attribute is not defined then we fall back to the + // text content of the option element, which may be interpolated if (isUndefined(attr.value)) { var interpolateFn = $interpolate(element.text(), true); if (!interpolateFn) { @@ -746,30 +247,39 @@ var optionDirective = ['$interpolate', function($interpolate) { } return function(scope, element, attr) { + + // This is an optimization over using ^^ since we don't want to have to search + // all the way to the root of the DOM for every single option element var selectCtrlName = '$selectController', parent = element.parent(), selectCtrl = parent.data(selectCtrlName) || parent.parent().data(selectCtrlName); // in case we are in optgroup - if (!selectCtrl || !selectCtrl.databound) { - selectCtrl = nullSelectCtrl; - } + // Only update trigger option updates if this is an option within a `select` + // that also has `ngModel` attached + if (selectCtrl && selectCtrl.ngModelCtrl) { - if (interpolateFn) { - scope.$watch(interpolateFn, function interpolateWatchAction(newVal, oldVal) { - attr.$set('value', newVal); - if (oldVal !== newVal) { - selectCtrl.removeOption(oldVal); - } - selectCtrl.addOption(newVal, element); + if (interpolateFn) { + scope.$watch(interpolateFn, function interpolateWatchAction(newVal, oldVal) { + attr.$set('value', newVal); + if (oldVal !== newVal) { + selectCtrl.removeOption(oldVal); + } + selectCtrl.addOption(newVal, element); + selectCtrl.ngModelCtrl.$render(); + chromeHack(element); + }); + } else { + selectCtrl.addOption(attr.value, element); + selectCtrl.ngModelCtrl.$render(); + chromeHack(element); + } + + element.on('$destroy', function() { + selectCtrl.removeOption(attr.value); + selectCtrl.ngModelCtrl.$render(); }); - } else { - selectCtrl.addOption(attr.value, element); } - - element.on('$destroy', function() { - selectCtrl.removeOption(attr.value); - }); }; } }; diff --git a/test/ng/directive/ngOptionsSpec.js b/test/ng/directive/ngOptionsSpec.js new file mode 100644 index 000000000000..21f890fc5406 --- /dev/null +++ b/test/ng/directive/ngOptionsSpec.js @@ -0,0 +1,1914 @@ +'use strict'; + +describe('ngOptions', function() { + + var scope, formElement, element, $compile; + + function compile(html) { + formElement = jqLite('<form name="form">' + html + '</form>'); + 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 = []; + var actual = this.actual.val(); + + if (multiple) { + value = value.map(function(val) { return hashKey(val); }); + actual = actual || []; + } else { + value = hashKey(value); + } + + if (!equals(actual, value)) { + errors.push('Expected select value "' + actual + '" 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 = '<select'; + forEach(attrs, function(value, key) { + if (isBoolean(value)) { + if (value) html += ' ' + key; + } else { + html += ' ' + key + '="' + value + '"'; + } + }); + html += '>' + + (blank ? (isString(blank) ? blank : '<option value="">blank</option>') : '') + + (unknown ? (isString(unknown) ? unknown : '<option value="?">unknown</option>') : '') + + '</select>'; + + 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); + } + + + it('should throw when not formated "? for ? in ?"', function() { + expect(function() { + compile('<select ng-model="selected" ng-options="i dont parse"></select>')(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 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('object.azur = "8888FF"'); + + options = element.find('option'); + expect(options[1].selected).toEqual(true); + + scope.$apply('selected = object.azur'); + + options = element.find('option'); + expect(options[3].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 not be set when an option is selected and options are set asynchronously', + inject(function($timeout) { + compile('<select ng-model="model" ng-options="opt.id as opt.label for opt in options">' + + '</select>'); + + 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'); + }) + ); + + + 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]); + }); + + + // 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'); + }); + + + + 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'}]; + 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']]); + }); + + 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(); + }); + }); + + + /** + * 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); + }); + }); + + + 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 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', + '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( + '<select ng-model="selected" ng-options="item.id as item.name for item in values"></select>' + ); + + 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( + '<select ng-model="selected" ng-options="val.id as val.name for (key, val) in values"></select>' + ); + + 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 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(); + + 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(); + }); + + + 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([]); + }); + + }); + + + describe('blank option', function() { + + it('should be compiled as template, be watched and updated', function() { + var option; + createSingleSelect('<option value="">blank is {{blankVal}}</option>'); + + 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('<option value="" ng-bind-template="blank is {{blankVal}}"></option>'); + + 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('<option value="" ng-bind="blankVal"></option>'); + + 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('<option value="" class="coyote" id="road-runner" ' + + 'custom-attr="custom-attr">{{blankVal}}</option>'); + + 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 = '<select ng-model="someModel" ng-options="c for c in choices">' + + '<option value="" disabled>Choose One</option>' + + '</select>'; + 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 = '<select ng-model="someModel" ng-options="c for c in choices" required>' + + '<option value="" disabled>Choose One</option>' + + '</select>'; + 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 2a624130fc69..725c87ddec97 100644 --- a/test/ng/directive/selectSpec.js +++ b/test/ng/directive/selectSpec.js @@ -10,6 +10,10 @@ describe('select', function() { scope.$apply(); } + function unknownValue(value) { + return '? ' + hashKey(value) + ' ?'; + } + beforeEach(inject(function($rootScope, _$compile_) { scope = $rootScope.$new(); //create a child scope because the root scope can't be $destroy-ed $compile = _$compile_; @@ -47,7 +51,8 @@ describe('select', function() { forEach(this.actual.find('option'), function(option) { optionGroup = option.parentNode.label || ''; actualValues[optionGroup] = actualValues[optionGroup] || []; - actualValues[optionGroup].push(option.label); + // IE9 doesn't populate the label property from the text property like other browsers + actualValues[optionGroup].push(option.label || option.text); }); this.message = function() { @@ -55,25 +60,6 @@ describe('select', function() { }; return equals(expected, actualValues); - }, - - toEqualOption: 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 value "' + this.actual.attr('value') + '" to equal "' + value + '"'); - } - if (label && this.actual.attr('label') !== label) { - errors.push('Expected option value "' + this.actual.attr('value') + '" to equal "' + value + '"'); - } - - this.message = function() { - return errors.join('\n'); - }; - - return errors.length === 0; } }); @@ -250,31 +236,6 @@ describe('select', function() { expect(scope.robot).toBe(''); }); - it('should not be set when an option is selected and options are set asynchronously', - inject(function($timeout) { - compile('<select ng-model="model" ng-options="opt.id as opt.label for opt in options">' + - '</select>'); - - 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'); - }) - ); describe('interactions with repeated options', function() { @@ -333,7 +294,7 @@ describe('select', function() { '<option>r2d2</option>' + '</select>'); - 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() { '<option>r2d2</option>' + '</select>'); - 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('<select ng-model="robot">' + '<option ng-repeat="r in robots">{{r}}</option>' + '</select>'); - 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,1941 +425,405 @@ 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('<select ng-model="robot" ' + - 'ng-options="item.value as item.label for item in robots">' + - '</select>'); - - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.robots.shift(); - }); - - expect(selectCtrl.hasOption('c3p0')).toBe(false); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); - - - it('should return false for options popped via ngOptions', function() { - scope.robots = [ - {value: 1, label: 'c3p0'}, - {value: 2, label: 'r2d2'} - ]; - - compile('<select ng-model="robot" ' + - 'ng-options="item.value as item.label for item in robots">' + - '</select>'); - - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.robots.pop(); - }); - - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(false); - }); - - - it('should return true for options added via ngOptions', function() { - scope.robots = [ - {value: 2, label: 'r2d2'} - ]; - - compile('<select ng-model="robot" ' + - 'ng-options="item.value as item.label for item in robots">' + - '</select>'); - - var selectCtrl = element.data().$selectController; - - scope.$apply(function() { - scope.robots.unshift({value: 1, label: 'c3p0'}); - }); - - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); - - - it('should keep all the options when changing the model', function() { - compile('<select ng-model="mySelect" ng-options="o for o in [\'A\',\'B\',\'C\']"></select>'); - 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']}); - }); - - - 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('<select ng-model="mySelect" ng-options="item.name group by item.group for item in values"></select>'); - 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']}); - }); - - - 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('<select ng-model="mySelect" ng-options="item.name group by item.group for item in values"></select>'); - 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 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('<select ng-model="mySelect" ng-options="item.name group by item.group for item in values"></select>'); - 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']}); - }); - - - 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('<select ng-model="mySelect" ng-options="item.name group by item.group for item in values"></select>'); - 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']}); - }); - - - 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('<select ng-model="mySelect" ng-options="item.name group by item.group for item in values"></select>'); - 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']}); - }); - - - 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('<select ng-model="mySelect" ng-options="item.name group by item.group for item in values"></select>'); - 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']}); - }); - }); - }); - }); - }); - - - describe('select-multiple', function() { - - it('should support type="select-multiple"', function() { - compile( - '<select ng-model="selection" multiple>' + - '<option>A</option>' + - '<option>B</option>' + - '</select>'); - - scope.$apply(function() { - scope.selection = ['A']; - }); - - expect(element).toEqualSelect(['A'], 'B'); - - scope.$apply(function() { - scope.selection.push('B'); }); - expect(element).toEqualSelect(['A'], ['B']); }); - it('should work with optgroups', function() { - compile('<select ng-model="selection" multiple>' + - '<optgroup label="group1">' + - '<option>A</option>' + - '<option>B</option>' + - '</optgroup>' + - '</select>'); + }); - expect(element).toEqualSelect('A', 'B'); - expect(scope.selection).toBeUndefined(); - scope.$apply(function() { - scope.selection = ['A']; - }); - expect(element).toEqualSelect(['A'], 'B'); + describe('selectController.hasOption', function() { - scope.$apply(function() { - scope.selection.push('B'); - }); - expect(element).toEqualSelect(['A'], ['B']); - }); + function compileRepeatedOptions() { + compile('<select ng-model="robot">' + + '<option value="{{item.value}}" ng-repeat="item in robots">{{item.label}}</option>' + + '</select>'); + } - it('should require', function() { + function compileGroupedOptions() { compile( - '<select name="select" ng-model="selection" multiple required>' + - '<option>A</option>' + - '<option>B</option>' + + '<select ng-model="mySelect">' + + '<option ng-repeat="item in values">{{item.name}}</option>' + + '<optgroup ng-repeat="group in groups" label="{{group.name}}">' + + '<option ng-repeat="item in group.values">{{item.name}}</option>' + + '</optgroup>' + '</select>'); + } - scope.$apply(function() { - scope.selection = []; - }); - - expect(scope.form.select.$error.required).toBeTruthy(); - expect(element).toBeInvalid(); - expect(element).toBePristine(); - - scope.$apply(function() { - scope.selection = ['A']; - }); - - expect(element).toBeValid(); - expect(element).toBePristine(); - - element[0].value = 'B'; - browserTrigger(element, 'change'); - expect(element).toBeValid(); - expect(element).toBeDirty(); - }); - - describe('selectController.hasOption', function() { - it('should return false for options shifted via ngOptions', function() { + describe('flat options', function() { + it('should return false for options shifted via ngRepeat', function() { scope.robots = [ {value: 1, label: 'c3p0'}, {value: 2, label: 'r2d2'} ]; - compile('<select ng-model="robot" multiple ' + - 'ng-options="item.value as item.label for item in robots">' + - '</select>'); + compileRepeatedOptions(); - var selectCtrl = element.data().$selectController; + var selectCtrl = element.controller('select'); scope.$apply(function() { scope.robots.shift(); }); - expect(selectCtrl.hasOption('c3p0')).toBe(false); - expect(selectCtrl.hasOption('r2d2')).toBe(true); + expect(selectCtrl.hasOption('1')).toBe(false); + expect(selectCtrl.hasOption('2')).toBe(true); }); - it('should return false for options popped via ngOptions', function() { + + it('should return false for options popped via ngRepeat', function() { scope.robots = [ {value: 1, label: 'c3p0'}, {value: 2, label: 'r2d2'} ]; - compile('<select ng-model="robot" multiple ' + - 'ng-options="item.value as item.label for item in robots">' + - '</select>'); + compileRepeatedOptions(); - var selectCtrl = element.data().$selectController; + var selectCtrl = element.controller('select'); scope.$apply(function() { scope.robots.pop(); }); - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(false); + expect(selectCtrl.hasOption('1')).toBe(true); + expect(selectCtrl.hasOption('2')).toBe(false); }); - it('should return true for options added via ngOptions', function() { + + it('should return true for options added via ngRepeat', function() { scope.robots = [ {value: 2, label: 'r2d2'} ]; - compile('<select ng-model="robot" multiple ' + - 'ng-options="item.value as item.label for item in robots">' + - '</select>'); + compileRepeatedOptions(); - var selectCtrl = element.data().$selectController; + var selectCtrl = element.controller('select'); scope.$apply(function() { scope.robots.unshift({value: 1, label: 'c3p0'}); }); - expect(selectCtrl.hasOption('c3p0')).toBe(true); - expect(selectCtrl.hasOption('r2d2')).toBe(true); - }); - }); - }); - - - describe('ngOptions', function() { - function createSelect(attrs, blank, unknown) { - var html = '<select'; - forEach(attrs, function(value, key) { - if (isBoolean(value)) { - if (value) html += ' ' + key; - } else { - html += ' ' + key + '="' + value + '"'; - } + expect(selectCtrl.hasOption('1')).toBe(true); + expect(selectCtrl.hasOption('2')).toBe(true); }); - html += '>' + - (blank ? (isString(blank) ? blank : '<option value="">blank</option>') : '') + - (unknown ? (isString(unknown) ? unknown : '<option value="?">unknown</option>') : '') + - '</select>'; - - 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); - } + it('should keep all the options when changing the model', function() { - 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'}}; - }); + compile('<select ng-model="mySelect"><option ng-repeat="o in [\'A\',\'B\',\'C\']">{{o}}</option></select>'); - it('should support single select with array source', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'item.id as item.label for item in arr' - }); + var selectCtrl = element.controller('select'); scope.$apply(function() { - scope.selected = 10; + scope.mySelect = 'C'; }); - expect(element.val()).toBe('0'); - element.val('1'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(20); + 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 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.val()).toEqual(['0','1']); - expect(scope.selected).toEqual([10,20]); + describe('grouped options', function() { - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([20]); - expect(element.val()).toEqual(['1']); - }); + 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 support single select with object source', function() { - createSelect({ - 'ng-model': 'selected', - 'ng-options': 'val.score as val.label for (key, val) in obj' - }); + var selectCtrl = element.controller('select'); scope.$apply(function() { - scope.selected = 10; + var itemD = scope.groups[0].values.pop(); + scope.groups[1].values.unshift(itemD); + scope.values.shift(); }); - expect(element.val()).toBe('10'); - element.val('20'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(20); + 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 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' - }); + 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() { - scope.selected = [10,20]; + var itemD = scope.groups[1].values.shift(); + scope.groups[0].values.push(itemD); + scope.values.shift(); }); - expect(element.val()).toEqual(['10','20']); - - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([20]); - expect(element.val()).toEqual(['20']); + 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']}); }); - }); - 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 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'} + ] + } + ]; - 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' - }); + compileGroupedOptions(); - scope.$apply(function() { - scope.selected = scope.arr[0]; - }); - expect(element.val()).toBe('10'); + var selectCtrl = element.controller('select'); scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; + var itemD = scope.groups[0].values.pop(); + scope.groups[1].values.unshift(itemD); + scope.groups[1].values.pop(); }); - expect(element.val()).toBe('10'); - - element.children()[1].selected = 'selected'; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.arr[1]); + 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 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)).toEqualOption('?', ''); - expect(options.eq(1)).toEqualOption('10', 'ten'); - expect(options.eq(2)).toEqualOption('20', 'twenty'); - }); + 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'} + ] + } + ]; - 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' - }); + compileGroupedOptions(); - scope.$apply(function() { - scope.selected = scope.arr[0]; - }); - expect(element.val()).toBe('10'); + var selectCtrl = element.controller('select'); scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; + scope.groups[0].values.pop(); + var itemD = scope.groups[1].values.shift(); + scope.groups[0].values.push(itemD); }); - expect(element.val()).toBe('10'); - - element.children()[1].selected = 1; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual(scope.arr[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']}); }); - 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' - }); + 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'} + ] + } + ]; - scope.$apply(function() { - scope.selected = scope.arr; - }); - expect(element.val()).toEqual(['10','20']); + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); scope.$apply(function() { - scope.arr[0] = {id: 10, label: 'new ten'}; + scope.groups[1].values.shift(); }); - expect(element.val()).toEqual(['10','20']); - - element.children()[0].selected = false; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.arr[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']}); }); - 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' - }); + 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'} + ] + } + ]; - scope.$apply(function() { - scope.selected = scope.obj['1']; - }); - expect(element.val()).toBe('10'); + compileGroupedOptions(); + + var selectCtrl = element.controller('select'); scope.$apply(function() { - scope.obj['1'] = {score: 10, label: 'ten'}; + scope.groups.pop(); }); - expect(element.val()).toBe('10'); - - element.val('20'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(scope.obj['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']}); }); + }); + }); + describe('select-multiple', function() { - 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']); + it('should support type="select-multiple"', function() { + compile( + '<select ng-model="selection" multiple>' + + '<option>A</option>' + + '<option>B</option>' + + '</select>'); - element.children()[1].selected = 'selected'; - browserTrigger(element, 'change'); - expect(scope.selected).toEqual([scope.obj['1'], scope.obj['2']]); + scope.$apply(function() { + scope.selection = ['A']; }); - }); + expect(element).toEqualSelect(['A'], 'B'); - /** - * 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.selection.push('B'); }); + expect(element).toEqualSelect(['A'], ['B']); + }); - 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 + it('should work with optgroups', function() { + compile('<select ng-model="selection" multiple>' + + '<optgroup label="group1">' + + '<option>A</option>' + + '<option>B</option>' + + '</optgroup>' + + '</select>'); - scope.$apply(function() { - scope.selected = scope.arr[0].subItem; - }); - expect(element.val()).toEqual('10'); + expect(element).toEqualSelect('A', 'B'); + expect(scope.selection).toBeUndefined(); - scope.$apply(function() { - scope.selected = scope.arr[1].subItem; - }); - expect(element.val()).toEqual('20'); + scope.$apply(function() { + scope.selection = ['A']; + }); + expect(element).toEqualSelect(['A'], 'B'); - // Now test view -> model + scope.$apply(function() { + scope.selection.push('B'); + }); + expect(element).toEqualSelect(['A'], ['B']); + }); - element.val('10'); - browserTrigger(element, 'change'); - expect(scope.selected).toBe(scope.arr[0].subItem); + it('should require', function() { + compile( + '<select name="select" ng-model="selection" multiple required>' + + '<option>A</option>' + + '<option>B</option>' + + '</select>'); - // 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.selection = []; }); + expect(scope.form.select.$error.required).toBeTruthy(); + expect(element).toBeInvalid(); + expect(element).toBePristine(); - 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)' - }); + scope.$apply(function() { + scope.selection = ['A']; + }); - // 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('<select ng-model="selected" ng-options="i dont parse"></select>'); - }).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[0]; - }); - - 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'); - }); - - 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('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.red; - }); - - 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); - - scope.$apply(function() { - scope.object.azur = '8888FF'; - }); - - 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 empty option - expect(element.find('option')).toEqualOption('?',''); - - 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('0', 'A'); - - scope.$apply(function() { - scope.values.push({name:'B'}); - }); - - 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'); - }); - - - 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('0', 'A'); - expect(element.find('option').eq(1)).toEqualOption('1', 'B'); - - scope.$apply(function() { - scope.values.pop(); - }); - - expect(element.find('option').length).toEqual(1); - expect(element.find('option')).toEqualOption('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('0', 'B'); - expect(options.eq(1)).toEqualOption('1', 'C'); - expect(options.eq(2)).toEqualOption('2', 'D'); - }); - - - it('should preserve existing options', 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 object\n' + - '| filter:isNotFoo', - 'ng-model': 'selected' - }); - - 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]; - }); - - 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); - - 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]); - }); - - // 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'); - }); - - 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.val()).toEqual('0'); - - scope.$apply(function() { - scope.selected = scope.values[1]; - }); - - expect(element.val()).toEqual('1'); - }); - - - 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'); - - 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.val()).toEqual('0'); - }); - - - 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 experession', 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.val()).toEqual('0'); - - scope.$apply(function() { - scope.selected = scope.values[1].id; - }); - - expect(element.val()).toEqual('1'); - }); - - it('should update options in the DOM', function() { - compile( - '<select ng-model="selected" ng-options="item.id as item.name for item in values"></select>' - ); - - 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('0', 'C'); - expect(options.eq(1)).toEqualOption('1', 'B'); - }); - - - it('should update options in the DOM from object source', function() { - compile( - '<select ng-model="selected" ng-options="val.id as val.name for (key, val) in values"></select>' - ); - - 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('a', 'C'); - expect(options.eq(1)).toEqualOption('b', '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.val()).toEqual('green'); - - scope.$apply(function() { - scope.selected = 'blue'; - }); - - expect(element.val()).toEqual('blue'); - }); - - - 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.val()).toEqual('green'); - - scope.$apply(function() { - scope.selected = '0000FF'; - }); - - 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(''); - - scope.$apply(function() { - scope.selected = scope.values[0]; - }); - - expect(element.val()).toEqual('0'); - 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.val()).toEqual('0'); - 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()).toEqual('?'); - expect(jqLite(element.find('option')[0]).val()).toEqual('?'); - - scope.$apply(function() { - scope.selected = scope.values[0]; - }); - - expect(element.val()).toEqual('0'); - 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()).toEqual('?'); - expect(element.find('option').eq(0).val()).toEqual('?'); - - 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); - }); - - - 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()).toEqual('?'); - 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.val()).toEqual('0'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - - // 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.values.push({id: 30, name: 'C'}); - }); - expect(element.val()).toEqual('1'); - expect(element.find('option').eq(0).attr('selected')).toEqual('selected'); - - // 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'); - }); - - - 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.val()).toBe('?'); - expect(option.text()).toBe(''); - }); - }); - - - describe('blank option', function() { - - it('should be compiled as template, be watched and updated', function() { - var option; - createSingleSelect('<option value="">blank is {{blankVal}}</option>'); - - 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('<option value="" ng-bind-template="blank is {{blankVal}}"></option>'); - - 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('<option value="" ng-bind="blankVal"></option>'); - - 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('<option value="" class="coyote" id="road-runner" ' + - 'custom-attr="custom-attr">{{blankVal}}</option>'); - - 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.val()).toEqual('0'); - - element.val('1'); - browserTrigger(element, 'change'); - 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.val()).toEqual('0'); - - element.val('1'); - browserTrigger(element, 'change'); - 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('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 = '<select ng-model="someModel" ng-options="c for c in choices">' + - '<option value="" disabled>Choose One</option>' + - '</select>'; - 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 = '<select ng-model="someModel" ng-options="c for c in choices" required>' + - '<option value="" disabled>Choose One</option>' + - '</select>'; - 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; - }); - - element.val('1'); - browserTrigger(element, 'change'); - expect(element).toBeValid(); - expect(scope.value).toBe(false); + expect(element).toBeValid(); + expect(element).toBePristine(); - scope.$apply(function() { - scope.required = true; - }); - expect(element).toBeValid(); - expect(scope.value).toBe(false); - }); + element[0].value = 'B'; + browserTrigger(element, 'change'); + expect(element).toBeValid(); + expect(element).toBeDirty(); }); - 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 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.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\']' - }); - - scope.form.select.$validators.fail = function() { - return false; - }; - - element.val('2'); - browserTrigger(element, 'change'); - expect(element).toBeInvalid(); - expect(scope.value).toBeUndefined(); - 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\']' - }); - - 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'); - }); - - 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; - }; - - 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\']' - }); - - 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'); - - defer.resolve(); - $rootScope.$digest(); - expect(scope.form.select.$pending).toBeUndefined(); - expect(scope.value).toBe('third'); - expect(element.val()).toBe('2'); - })); - }); }); @@ -2406,12 +831,12 @@ describe('select', function() { it('should populate value attribute on OPTION', function() { compile('<select ng-model="x"><option selected>abc</option></select>'); - expect(element).toEqualSelect(['? undefined:undefined ?'], 'abc'); + expect(element).toEqualSelect([unknownValue(undefined)], 'abc'); }); it('should ignore value if already exists', function() { compile('<select ng-model="x"><option value="abc">xyz</option></select>'); - expect(element).toEqualSelect(['? undefined:undefined ?'], 'abc'); + expect(element).toEqualSelect([unknownValue(undefined)], 'abc'); }); it('should set value even if self closing HTML', function() {