feat(select): expose info about selection state in controller

This allows custom directives to manipulate the select's and
ngModel's behavior based on the state of the unknown and
the empty option.

Closes #13172
Closes #10127
This commit is contained in:
Martin Staffa
2017-04-25 11:43:45 +02:00
committed by Martin Staffa
parent 2fdfbe7296
commit 6fc0f02ad2
3 changed files with 354 additions and 9 deletions
+159 -3
View File
@@ -19,10 +19,120 @@ function setOptionSelectedStatus(optionEl, value) {
/**
* @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.
* The controller for the {@link ng.select select} directive. The controller exposes
* a few utility methods that can be used to augment the behavior of a regular or an
* {@link ng.ngOptions ngOptions} select element.
*
* @example
* ### Set a custom error when the unknown option is selected
*
* This example sets a custom error "unknownValue" on the ngModelController
* when the select element's unknown option is selected, i.e. when the model is set to a value
* that is not matched by any option.
*
* <example name="select-unknown-value-error" module="staticSelect">
* <file name="index.html">
* <div ng-controller="ExampleController">
* <form name="myForm">
* <label for="testSelect"> Single select: </label><br>
* <select name="testSelect" ng-model="selected" unknown-value-error>
* <option value="option-1">Option 1</option>
* <option value="option-2">Option 2</option>
* </select><br>
* <span ng-if="myForm.testSelect.$error.unknownValue">Error: The current model doesn't match any option</span>
*
* <button ng-click="forceUnknownOption()">Force unknown option</button><br>
* </form>
* </div>
* </file>
* <file name="app.js">
* angular.module('staticSelect', [])
* .controller('ExampleController', ['$scope', function($scope) {
* $scope.selected = null;
*
* $scope.forceUnknownOption = function() {
* $scope.selected = 'nonsense';
* };
* }])
* .directive('unknownValueError', function() {
* return {
* require: ['ngModel', 'select'],
* link: function(scope, element, attrs, ctrls) {
* var ngModelCtrl = ctrls[0];
* var selectCtrl = ctrls[1];
*
* ngModelCtrl.$validators.unknownValue = function(modelValue, viewValue) {
* if (selectCtrl.$isUnknownOptionSelected()) {
* return false;
* }
*
* return true;
* };
* }
*
* };
* });
* </file>
*</example>
*
*
* @example
* ### Set the "required" error when the unknown option is selected.
*
* By default, the "required" error on the ngModelController is only set on a required select
* when the empty option is selected. This example adds a custom directive that also sets the
* error when the unknown option is selected.
*
* <example name="select-unknown-value-required" module="staticSelect">
* <file name="index.html">
* <div ng-controller="ExampleController">
* <form name="myForm">
* <label for="testSelect"> Select: </label><br>
* <select name="testSelect" ng-model="selected" unknown-value-required>
* <option value="option-1">Option 1</option>
* <option value="option-2">Option 2</option>
* </select><br>
* <span ng-if="myForm.testSelect.$error.required">Error: Please select a value</span><br>
*
* <button ng-click="forceUnknownOption()">Force unknown option</button><br>
* </form>
* </div>
* </file>
* <file name="app.js">
* angular.module('staticSelect', [])
* .controller('ExampleController', ['$scope', function($scope) {
* $scope.selected = null;
*
* $scope.forceUnknownOption = function() {
* $scope.selected = 'nonsense';
* };
* }])
* .directive('unknownValueRequired', function() {
* return {
* priority: 1, // This directive must run after the required directive has added its validator
* require: ['ngModel', 'select'],
* link: function(scope, element, attrs, ctrls) {
* var ngModelCtrl = ctrls[0];
* var selectCtrl = ctrls[1];
*
* var originalRequiredValidator = ngModelCtrl.$validators.required;
*
* ngModelCtrl.$validators.required = function() {
* if (attrs.required && selectCtrl.$isUnknownOptionSelected()) {
* return false;
* }
*
* return originalRequiredValidator.apply(this, arguments);
* };
* }
* };
* });
* </file>
*</example>
*
*
*/
var SelectController =
['$element', '$scope', /** @this */ function($element, $scope) {
@@ -172,6 +282,49 @@ var SelectController =
return !!optionsMap.get(value);
};
/**
* @ngdoc method
* @name select.SelectController#$hasEmptyOption
*
* @description
*
* Returns `true` if the select element currently has an empty option
* element, i.e. an option that signifies that the select is empty / the selection is null.
*
*/
self.$hasEmptyOption = function() {
return self.hasEmptyOption;
};
/**
* @ngdoc method
* @name select.SelectController#$isUnknownOptionSelected
*
* @description
*
* Returns `true` if the select element's unknown option is selected. The unknown option is added
* and automatically selected whenever the select model doesn't match any option.
*
*/
self.$isUnknownOptionSelected = function() {
// Presence of the unknown option means it is selected
return $element[0].options[0] === self.unknownOption[0];
};
/**
* @ngdoc method
* @name select.SelectController#$isEmptyOptionSelected
*
* @description
*
* Returns `true` if the select element has an empty option and this empty option is currently
* selected. Returns `false` if the select element has no empty option or it is not selected.
*
*/
self.$isEmptyOptionSelected = function() {
return self.hasEmptyOption && $element[0].options[$element[0].selectedIndex] === self.emptyOption[0];
};
self.selectUnknownOrEmptyOption = function(value) {
if (value == null && self.emptyOption) {
self.removeUnknownOption();
@@ -330,6 +483,9 @@ var SelectController =
* the content of the `value` attribute or the textContent of the `<option>`, if the value attribute is missing.
* Value and textContent can be interpolated.
*
* The {@link select.selectController select controller} exposes utility functions that can be used
* to manipulate the select's behavior.
*
* ## Matching model and option values
*
* In general, the match between the model and an option is evaluated by strictly comparing the model
+90
View File
@@ -3305,4 +3305,94 @@ describe('ngOptions', function() {
expect(scope.form.select.$pristine).toBe(true);
});
});
describe('selectCtrl api', function() {
it('should reflect the status of empty and unknown option', function() {
createSingleSelect('<option ng-if="isBlank" value="">blank</option>');
var selectCtrl = element.controller('select');
scope.$apply(function() {
scope.values = [{name: 'A'}, {name: 'B'}];
scope.isBlank = true;
});
expect(element).toEqualSelect([''], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// empty -> selection
scope.$apply(function() {
scope.selected = scope.values[0];
});
expect(element).toEqualSelect('', ['object:4'], 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// remove empty
scope.$apply('isBlank = false');
expect(element).toEqualSelect(['object:4'], 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// selection -> unknown
scope.$apply('selected = "unmatched"');
expect(element).toEqualSelect(['?'], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
// add empty
scope.$apply('isBlank = true');
expect(element).toEqualSelect(['?'], '', 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
// unknown -> empty
scope.$apply(function() {
scope.selected = null;
});
expect(element).toEqualSelect([''], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// empty -> unknown
scope.$apply('selected = "unmatched"');
expect(element).toEqualSelect(['?'], '', 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
// unknown -> selection
scope.$apply(function() {
scope.selected = scope.values[1];
});
expect(element).toEqualSelect('', 'object:4', ['object:5']);
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// selection -> empty
scope.$apply('selected = null');
expect(element).toEqualSelect([''], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
});
});
});
+105 -6
View File
@@ -434,34 +434,30 @@ describe('select', function() {
describe('empty option', function() {
it('should allow empty option to be added and removed dynamically', function() {
scope.dynamicOptions = [];
scope.robot = '';
compile('<select ng-model="robot">' +
'<option ng-repeat="opt in dynamicOptions" value="{{opt.val}}">{{opt.display}}</option>' +
'</select>');
expect(element).toEqualSelect(['? string: ?']);
scope.dynamicOptions = [
{ val: '', display: '--select--' },
{ val: '', display: '--empty--'},
{ val: 'x', display: 'robot x' },
{ val: 'y', display: 'robot y' }
];
scope.$digest();
expect(element).toEqualSelect([''], 'x', 'y');
scope.robot = 'x';
scope.$digest();
expect(element).toEqualSelect('', ['x'], 'y');
scope.dynamicOptions.shift();
scope.$digest();
expect(element).toEqualSelect(['x'], 'y');
scope.robot = undefined;
scope.$digest();
expect(element).toEqualSelect([unknownValue(undefined)], 'x', 'y');
@@ -839,6 +835,109 @@ describe('select', function() {
});
});
describe('selectController', function() {
it('should expose .$hasEmptyOption(), .$isEmptyOptionSelected(), ' +
'and .$isUnknownOptionSelected()', function() {
compile('<select ng-model="mySelect"></select>');
var selectCtrl = element.controller('select');
expect(selectCtrl.$hasEmptyOption).toEqual(jasmine.any(Function));
expect(selectCtrl.$isEmptyOptionSelected).toEqual(jasmine.any(Function));
expect(selectCtrl.$isUnknownOptionSelected).toEqual(jasmine.any(Function));
}
);
it('should reflect the status of empty and unknown option', function() {
scope.dynamicOptions = [];
scope.selected = '';
compile('<select ng-model="selected">' +
'<option ng-if="empty" value="">--no selection--</option>' +
'<option ng-repeat="opt in dynamicOptions" value="{{opt.val}}">{{opt.display}}</option>' +
'</select>');
var selectCtrl = element.controller('select');
expect(element).toEqualSelect(['? string: ?']);
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
scope.dynamicOptions = [
{ val: 'x', display: 'robot x' },
{ val: 'y', display: 'robot y' }
];
scope.empty = true;
scope.$digest();
expect(element).toEqualSelect([''], 'x', 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// empty -> selection
scope.$apply('selected = "x"');
expect(element).toEqualSelect('', ['x'], 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// remove empty
scope.$apply('empty = false');
expect(element).toEqualSelect(['x'], 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// selection -> unknown
scope.$apply('selected = "unmatched"');
expect(element).toEqualSelect([unknownValue('unmatched')], 'x', 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
// add empty
scope.$apply('empty = true');
expect(element).toEqualSelect([unknownValue('unmatched')], '', 'x', 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
// unknown -> empty
scope.$apply('selected = null');
expect(element).toEqualSelect([''], 'x', 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// empty -> unknown
scope.$apply('selected = "unmatched"');
expect(element).toEqualSelect([unknownValue('unmatched')], '', 'x', 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
// unknown -> selection
scope.$apply('selected = "y"');
expect(element).toEqualSelect('', 'x', ['y']);
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
// selection -> empty
scope.$apply('selected = null');
expect(element).toEqualSelect([''], 'x', 'y');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
});
});
describe('selectController.hasOption', function() {