Skip to content

Commit

Permalink
Merge pull request knockout#647 from knockout/647-allow-unselected-value
Browse files Browse the repository at this point in the history
Support select value not in options list, optionally
  • Loading branch information
SteveSanderson committed Feb 2, 2014
2 parents 65d6906 + 4c58eb8 commit d8f1dd5
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 35 deletions.
112 changes: 111 additions & 1 deletion spec/defaultBindings/valueBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,29 @@ describe('Binding: Value', function() {
expect(testNode.childNodes[0].selectedIndex).toEqual(2);
observable("");
expect(testNode.childNodes[0].selectedIndex).toEqual(0);
});

it('When size > 1, should unselect all options when value is undefined, null, or \"\"', function() {
var observable = new ko.observable('B');
testNode.innerHTML = "<select size='2' data-bind='options:[\"A\", \"B\"], value:myObservable'></select>";
ko.applyBindings({ myObservable: observable }, testNode);

// Nothing is selected when observable changed to undefined
expect(testNode.childNodes[0].selectedIndex).toEqual(1);
observable(undefined);
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);

// Nothing is selected when observable changed to null
observable("B");
expect(testNode.childNodes[0].selectedIndex).toEqual(1);
observable(null);
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);

// Nothing is selected when observable changed to ""
observable("B");
expect(testNode.childNodes[0].selectedIndex).toEqual(1);
observable("");
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);
});

it('Should update the model value when the UI is changed (setting it to undefined when the caption is selected)', function () {
Expand Down Expand Up @@ -384,7 +406,10 @@ describe('Binding: Value', function() {
expect(testNode.childNodes[0].selectedIndex).toEqual(1);

observable('D'); // This change should be rejected, as there's no corresponding option in the UI
expect(observable()).not.toEqual('D');
expect(observable()).toEqual('B');

observable(null); // This change should also be rejected
expect(observable()).toEqual('B');
});

it('Should support numerical option values, which are not implicitly converted to strings', function() {
Expand Down Expand Up @@ -428,5 +453,90 @@ describe('Binding: Value', function() {
observable('C');
expect(dropdown.selectedIndex).toEqual(2);
});

describe('Using valueAllowUnset option', function () {
it('Should display the caption when the model value changes to undefined, null, or \"\" when using \'options\' binding', function() {
var observable = ko.observable('B');
testNode.innerHTML = "<select data-bind='options:[\"A\", \"B\"], optionsCaption:\"Select...\", value:myObservable, valueAllowUnset:true'></select>";
ko.applyBindings({ myObservable: observable }, testNode);
var select = testNode.childNodes[0];

select.selectedIndex = 2;
observable(undefined);
expect(select.selectedIndex).toEqual(0);

select.selectedIndex = 2;
observable(null);
expect(select.selectedIndex).toEqual(0);

select.selectedIndex = 2;
observable("");
expect(select.selectedIndex).toEqual(0);
});

it('Should display the caption when the model value changes to undefined, null, or \"\" when options specified directly', function() {
var observable = ko.observable('B');
testNode.innerHTML = "<select data-bind='value:myObservable, valueAllowUnset:true'><option value=''>Select...</option><option>A</option><option>B</option></select>";
ko.applyBindings({ myObservable: observable }, testNode);
var select = testNode.childNodes[0];

select.selectedIndex = 2;
observable(undefined);
expect(select.selectedIndex).toEqual(0);

select.selectedIndex = 2;
observable(null);
expect(select.selectedIndex).toEqual(0);

select.selectedIndex = 2;
observable("");
expect(select.selectedIndex).toEqual(0);
});

it('Should select no option value if no option value matches the current model property value', function() {
var observable = ko.observable();
testNode.innerHTML = "<select data-bind='options:[\"A\", \"B\"], value:myObservable, valueAllowUnset:true'></select>";
ko.applyBindings({ myObservable: observable }, testNode);

expect(testNode.childNodes[0].selectedIndex).toEqual(-1);
expect(observable()).toEqual(undefined);
});

it('Should select no option value if model value does\'t match any option value', function() {
var observable = ko.observable('B');
testNode.innerHTML = "<select data-bind='options:[\"A\", \"B\", \"C\"], value:myObservable, valueAllowUnset:true'></select>";
ko.applyBindings({ myObservable: observable }, testNode);
expect(testNode.childNodes[0].selectedIndex).toEqual(1);

observable('D');
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);
});

it('Should maintain model value and update selection when options change', function() {
var observable = ko.observable("D");
var options = ko.observableArray(["A", "B"]);
testNode.innerHTML = "<select data-bind='options:myOptions, value:myObservable, valueAllowUnset:true'></select>";
ko.applyBindings({ myObservable: observable, myOptions: options }, testNode);

// Initially nothing is selected because the value isn't in the options list
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);
expect(observable()).toEqual("D");

// Replace with new options that still don't contain the value
options(["B", "C"]);
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);
expect(observable()).toEqual("D");

// Now update with options that do contain the value
options(["C", "D"]);
expect(testNode.childNodes[0].selectedIndex).toEqual(1);
expect(observable()).toEqual("D");

// Update back to options that don't contain the value
options(["E", "F"]);
expect(testNode.childNodes[0].selectedIndex).toEqual(-1);
expect(observable()).toEqual("D");
});
});
});
});
44 changes: 26 additions & 18 deletions src/binding/defaultBindings/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,25 +121,33 @@ ko.bindingHandlers['options'] = {

ko.utils.setDomNodeChildrenFromArrayMapping(element, filteredArray, optionForArrayItem, arrayToDomNodeChildrenOptions, callback);

// Determine if the selection has changed as a result of updating the options list
var selectionChanged;
if (element.multiple) {
// For a multiple-select box, compare the new selection count to the previous one
// But if nothing was selected before, the selection can't have changed
selectionChanged = previousSelectedValues.length && selectedOptions().length < previousSelectedValues.length;
} else {
// For a single-select box, compare the current value to the previous value
// But if nothing was selected before or nothing is selected now, just look for a change in selection
selectionChanged = (previousSelectedValues.length && element.selectedIndex >= 0)
? (ko.selectExtensions.readValue(element.options[element.selectedIndex]) !== previousSelectedValues[0])
: (previousSelectedValues.length || element.selectedIndex >= 0);
}
ko.dependencyDetection.ignore(function () {
if (allBindings.get('valueAllowUnset') && allBindings['has']('value')) {
// The model value is authoritative, so make sure its value is the one selected
ko.selectExtensions.writeValue(element, ko.utils.unwrapObservable(allBindings.get('value')), true /* allowUnset */);
} else {
// Determine if the selection has changed as a result of updating the options list
var selectionChanged;
if (element.multiple) {
// For a multiple-select box, compare the new selection count to the previous one
// But if nothing was selected before, the selection can't have changed
selectionChanged = previousSelectedValues.length && selectedOptions().length < previousSelectedValues.length;
} else {
// For a single-select box, compare the current value to the previous value
// But if nothing was selected before or nothing is selected now, just look for a change in selection
selectionChanged = (previousSelectedValues.length && element.selectedIndex >= 0)
? (ko.selectExtensions.readValue(element.options[element.selectedIndex]) !== previousSelectedValues[0])
: (previousSelectedValues.length || element.selectedIndex >= 0);
}

// Ensure consistency between model value and selected option.
// If the dropdown was changed so that selection is no longer the same,
// notify the value or selectedOptions binding.
if (selectionChanged)
ko.dependencyDetection.ignore(ko.utils.triggerEvent, null, [element, "change"]);
// Ensure consistency between model value and selected option.
// If the dropdown was changed so that selection is no longer the same,
// notify the value or selectedOptions binding.
if (selectionChanged) {
ko.utils.triggerEvent(element, "change");
}
}
});

// Workaround for IE bug
ko.utils.ensureSelectElementIsRenderedCorrectly(element);
Expand Down
16 changes: 10 additions & 6 deletions src/binding/defaultBindings/value.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,20 @@ ko.bindingHandlers['value'] = {
ko.utils.registerEventHandler(element, eventName, handler);
});
},
'update': function (element, valueAccessor) {
var valueIsSelectOption = ko.utils.tagNameLower(element) === "select";
'update': function (element, valueAccessor, allBindings) {
var newValue = ko.utils.unwrapObservable(valueAccessor());
var elementValue = ko.selectExtensions.readValue(element);
var valueHasChanged = (newValue !== elementValue);

if (valueHasChanged) {
var applyValueAction = function () { ko.selectExtensions.writeValue(element, newValue); };
applyValueAction();
if (ko.utils.tagNameLower(element) === "select") {
var allowUnset = allBindings.get('valueAllowUnset');
var applyValueAction = function () {
ko.selectExtensions.writeValue(element, newValue, allowUnset);
};
applyValueAction();

if (valueIsSelectOption) {
if (newValue !== ko.selectExtensions.readValue(element)) {
if (!allowUnset && newValue !== ko.selectExtensions.readValue(element)) {
// If you try to set a model value that can't be represented in an already-populated dropdown, reject that change,
// because you're not allowed to have a model value that disagrees with a visible UI selection.
ko.dependencyDetection.ignore(ko.utils.triggerEvent, null, [element, "change"]);
Expand All @@ -65,6 +67,8 @@ ko.bindingHandlers['value'] = {
// to apply the value as well.
setTimeout(applyValueAction, 0);
}
} else {
ko.selectExtensions.writeValue(element, newValue);
}
}
}
Expand Down
20 changes: 10 additions & 10 deletions src/binding/selectExtensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
}
},

writeValue: function(element, value) {
writeValue: function(element, value, allowUnset) {
switch (ko.utils.tagNameLower(element)) {
case 'option':
switch(typeof value) {
Expand All @@ -42,19 +42,19 @@
}
break;
case 'select':
if (value === "")
if (value === "" || value === null) // A blank string or null value will select the caption
value = undefined;
if (value === null || value === undefined)
element.selectedIndex = -1;
for (var i = element.options.length - 1; i >= 0; i--) {
if (ko.selectExtensions.readValue(element.options[i]) == value) {
element.selectedIndex = i;
var selection = -1;
for (var i = 0, n = element.options.length, optionValue; i < n; ++i) {
optionValue = ko.selectExtensions.readValue(element.options[i]);
// Include special check to handle selecting a caption with a blank string value
if (optionValue == value || (optionValue == "" && value === undefined)) {
selection = i;
break;
}
}
// for drop-down select, ensure first is selected
if (!(element.size > 1) && element.selectedIndex === -1) {
element.selectedIndex = 0;
if (allowUnset || selection >= 0 || (value === undefined && element.size > 1)) {
element.selectedIndex = selection;
}
break;
default:
Expand Down

0 comments on commit d8f1dd5

Please sign in to comment.