fix(ngOptions): select unknown option if unmatched model does not match empty option

When a regular / ngOptions select has an explicit *empty* option, this option can be selected
by the user and will set the model to `null`. It is also selected when the model is set to
`null` or `undefined`.

When the model is set to a value that does not match any option value, and is also not
`null` or `undefined`, the *unknown* option is inserted and selected - this is an explicit marker
that the select is in an invalid / unknown state, which is different from an allowed empty state.

Previously, regular selects followed this logic, whereas ngOptions selects selected the empty
option in the case described above.

This patch makes the behavior consistent between regular / ngOptions select - the latter will now
insert and select the unknown option. The order of the options has been fixed to unknown -> empty
-> actual options.
This commit is contained in:
Martin Staffa
2017-04-18 15:24:33 +02:00
committed by Martin Staffa
parent 8d7c7f4a8e
commit 5878f07474
5 changed files with 113 additions and 37 deletions
+8 -2
View File
@@ -473,7 +473,8 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
option.element.setAttribute('selected', 'selected');
} else {
if (providedEmptyOption) {
if (value == null && providedEmptyOption) {
selectCtrl.removeUnknownOption();
selectCtrl.selectEmptyOption();
} else if (selectCtrl.unknownOption.parent().length) {
selectCtrl.updateUnknownOption(value);
@@ -657,7 +658,12 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
// Ensure that the empty option is always there if it was explicitly provided
if (providedEmptyOption) {
selectElement.prepend(selectCtrl.emptyOption);
if (selectCtrl.unknownOption.parent().length) {
selectCtrl.unknownOption.after(selectCtrl.emptyOption);
} else {
selectElement.prepend(selectCtrl.emptyOption);
}
}
options.items.forEach(function addOption(option) {
+7 -5
View File
@@ -44,11 +44,13 @@ var SelectController =
// to create it in <select> and IE barfs otherwise.
self.unknownOption = jqLite(window.document.createElement('option'));
// The empty option is an option with the value '' that te application developer can
// provide inside the select. When the model changes to a value that doesn't match an option,
// it is selected - so if an empty option is provided, no unknown option is generated.
// However, the empty option is not removed when the model matches an option. It is always selectable
// and indicates that a "null" selection has been made.
// The empty option is an option with the value '' that the application developer can
// provide inside the select. It is always selectable and indicates that a "null" selection has
// been made by the user.
// If the select has an empty option, and the model of the select is set to "undefined" or "null",
// the empty option is selected.
// If the model is set to a different unmatched value, the unknown option is rendered and
// selected, i.e both are present, because a "null" selection and an unknown value are different.
self.hasEmptyOption = false;
self.emptyOption = undefined;
+21
View File
@@ -400,6 +400,27 @@ beforeEach(function() {
return result;
}
};
},
toEqualSelect: function() {
return {
compare: function(actual, expected) {
var actualValues = [],
expectedValues = [].slice.call(arguments, 1);
forEach(actual.find('option'), function(option) {
actualValues.push(option.selected ? [option.value] : option.value);
});
var message = function() {
return 'Expected ' + toJson(actualValues) + ' to equal ' + toJson(expectedValues) + '.';
};
return {
pass: equals(expectedValues, actualValues),
message: message
};
}
};
}
});
});
+49 -2
View File
@@ -773,12 +773,32 @@ describe('ngOptions', function() {
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).toBeMarkedAsSelected();
scope.selected = 'no match';
// This will select the empty option
scope.selected = null;
scope.$digest();
expect(options[0]).toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).not.toBeMarkedAsSelected();
// This will add and select the unknown option
scope.selected = 'unmatched value';
scope.$digest();
options = element.find('option');
expect(options[0]).toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).not.toBeMarkedAsSelected();
expect(options[3]).not.toBeMarkedAsSelected();
// Back to matched value
scope.selected = scope.values[1];
scope.$digest();
options = element.find('option');
expect(options[0]).not.toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).toBeMarkedAsSelected();
});
describe('disableWhen expression', function() {
@@ -2195,6 +2215,20 @@ describe('ngOptions', function() {
});
it('should insert and select temporary unknown option when no options-model match, empty ' +
'option is present and model is defined', function() {
scope.selected = 'C';
scope.values = [{name: 'A'}, {name: 'B'}];
createSingleSelect(true);
expect(element).toEqualSelect(['?'], '', 'object:3', 'object:4');
scope.$apply('selected = values[1]');
expect(element).toEqualSelect('', 'object:3', ['object:4']);
});
it('should select correct input if previously selected option was "?"', function() {
createSingleSelect();
@@ -2214,6 +2248,19 @@ describe('ngOptions', function() {
});
it('should remove unknown option when empty option exists and model is undefined', function() {
scope.selected = 'C';
scope.values = [{name: 'A'}, {name: 'B'}];
createSingleSelect(true);
expect(element).toEqualSelect(['?'], '', 'object:3', 'object:4');
scope.selected = undefined;
scope.$digest();
expect(element).toEqualSelect([''], 'object:3', 'object:4');
});
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];
@@ -2955,7 +3002,7 @@ describe('ngOptions', function() {
expect(ngModelCtrl.$error.required).toBeFalsy();
// model -> view
scope.$apply('selection = "unmatched value"');
scope.$apply('selection = null');
expect(options[0]).toBeMarkedAsSelected();
expect(element).toBeInvalid();
expect(ngModelCtrl.$error.required).toBeTruthy();
+28 -28
View File
@@ -86,28 +86,6 @@ describe('select', function() {
beforeEach(function() {
jasmine.addMatchers({
toEqualSelect: function() {
return {
compare: function(actual, expected) {
var actualValues = [],
expectedValues = [].slice.call(arguments, 1);
forEach(actual.find('option'), function(option) {
actualValues.push(option.selected ? [option.value] : option.value);
});
var message = function() {
return 'Expected ' + toJson(actualValues) + ' to equal ' + toJson(expectedValues) + '.';
};
return {
pass: equals(expectedValues, actualValues),
message: message
};
}
};
},
toEqualSelectWithOptions: function() {
return {
compare: function(actual, expected) {
@@ -396,6 +374,7 @@ describe('select', function() {
it('should remove the "selected" attribute from the previous option when the model changes', function() {
compile('<select name="select" ng-model="selected">' +
'<option value="">--empty--</option>' +
'<option value="a">A</option>' +
'<option value="b">B</option>' +
'</select>');
@@ -411,24 +390,45 @@ describe('select', function() {
scope.$digest();
options = element.find('option');
expect(options.length).toBe(2);
expect(options[0]).toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options.length).toBe(3);
expect(options[0]).not.toBeMarkedAsSelected();
expect(options[1]).toBeMarkedAsSelected();
expect(options[2]).not.toBeMarkedAsSelected();
scope.selected = 'b';
scope.$digest();
options = element.find('option');
expect(options[0]).not.toBeMarkedAsSelected();
expect(options[1]).toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).toBeMarkedAsSelected();
scope.selected = 'no match';
// This will select the empty option
scope.selected = null;
scope.$digest();
options = element.find('option');
expect(options[0]).toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).not.toBeMarkedAsSelected();
// This will add and select the unknown option
scope.selected = 'unmatched value';
scope.$digest();
options = element.find('option');
expect(options[0]).toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).not.toBeMarkedAsSelected();
expect(options[3]).not.toBeMarkedAsSelected();
// Back to matched value
scope.selected = 'b';
scope.$digest();
options = element.find('option');
expect(options[0]).not.toBeMarkedAsSelected();
expect(options[1]).not.toBeMarkedAsSelected();
expect(options[2]).toBeMarkedAsSelected();
});
describe('empty option', function() {