3

I think this has got to be a common situation and I'm wondering if there's an accepted convention of how to handle this in Knockout. You have a "yes-no" dropdown (or pair of radio buttons), and it's default value is a blank item (or both unchecked, for the radio buttons). The user must make a choice in order to continue.

This doesn't perfectly map to a boolean value in your model, because there are actually three possible values. True, false, and no user selection. In C# you might consider using a nullable boolean, and in Java you might use java.lang.Boolean. In both cases, "null" could represent no user selection.

JavaScript doesn't have nullables, but since it does not enforce variable types, you could adopt a convention that a particular variable can be null, true, or false, and use it in a similar fashion to a nullable boolean in C# or a java.lang.Boolean.

First off, there's the issue of binding to boolean. Knockout wants all bound values to be string types by default. This is discussed here and here, and the solution provided by RP Niemeyer is to use a custom binding, like this: (Link to JS Fiddle for this example)

ko.bindingHandlers.booleanValue = {
    init: function(element, valueAccessor, allBindingsAccessor) {
        var observable = valueAccessor(),
            interceptor = ko.computed({
                read: function() {
                    return observable().toString();
                },
                write: function(newValue) {
                    observable(newValue === "true");
                }                   
            });

        ko.applyBindingsToNode(element, { value: interceptor });
    }
};

So I used this as my starting point, and I came up with this custom binding. It seems to work. I'm interested in community feedback on this approach. Checkout the jsfiddle for this to experiment with it yourself. Any drawbacks and/or scalability issues with this?

ko.bindingHandlers.nullableBooleanValue = {
    init: function(element, valueAccessor, allBindingsAccessor) {
        var observable = valueAccessor(),
            interceptor = ko.computed({
                read: function() {                                           
                    console.log(observable());
                    console.log(typeof(observable()));

                    var result = null;
                    if(observable() === true){
                        result = "true";
                    } else if(observable() === false){
                        result = "false";
                    } else { // Default is null, which represents no user selection
                        result = "null";
                    }

                    console.log("transforming on read:")
                    console.log(typeof(observable()));
                    console.log(observable());
                    console.log(typeof(result));
                    console.log(result);
                    return result;
                },
                write: function(newValue) {
                    var result = null;
                    if(newValue === "true"){
                        result = true;
                    } else if(newValue === "false"){
                        result = false;
                    } else { // Default is null, which represents no user selection
                        result = null;
                    }

                    console.log("transforming on write:")
                    console.log(typeof(newValue));
                    console.log(newValue);
                    console.log(typeof(result));
                    console.log(result);
                    observable(result);
                }                   
            });

        ko.applyBindingsToNode(element, { value: interceptor });
    }
};

var model = {
    state: ko.observable(null)
};

ko.applyBindings(model);
Community
  • 1
  • 1
Josh
  • 7,232
  • 8
  • 48
  • 75

1 Answers1

3

Ok, the extender method just did not work the way I wanted it do, so I dropped it (still in the edit history, if you're curious). I modified your binding to have it place the options on, so that you aren't specifying them in the HTML. You also have the option to specify the "Null" option text (you could expand this to allow setting each label).

This method lets you treat the observable like a standard nullable boolean. Here is the HTML (note, the nullLabel is completely optional):

<select data-bind="yesNoNull: answer, nullLabel: 'Null' "></select>
<pre data-bind="text: ko.toJSON($root, null, 2)"></pre>​

Here is the binding:

ko.bindingHandlers.yesNoNull = {    
    init: function(element, valueAccessor, allBindingsAccessor) {
        var target = valueAccessor();
        var nullLabel = allBindingsAccessor().nullLabel || "";
        var options = function() { return [ nullLabel, "Yes", "No"]; };
        ko.bindingHandlers.options.update(element, options, allBindingsAccessor);

        var observable = valueAccessor(),
            interceptor = ko.computed({
                read: function() {
                    var result = nullLabel;
                    if(observable() === true){
                        result = "Yes";
                    } else if(observable() === false){
                        result = "No";
                    }
                    return result;
                },
                write: function(newValue) {
                    var result = null;
                    if(newValue === "Yes"){
                        result = true;
                    } else if(newValue === "No"){
                        result = false;
                    }
                    observable(result);
                }                   
            });

        ko.applyBindingsToNode(element, { value: interceptor });
    }
};

And here is the fiddle.

Kyeotic
  • 19,697
  • 10
  • 71
  • 128
  • Could you explain what this line is for? `interceptor.options = [options || "", "Yes", "No"];` If I'm reading your example correctly, interceptor.options will be set to the empty string "". But I don't see where or how it gets used from there. Thanks! – Josh Nov 09 '12 at 16:03
  • Thanks for the clarification. However, something is not working right. I'm not sure if I'm doing something wrong or what, but when I try to assign a value to the ViewModel field "answer", the change is not propagated back to the select element. [Here's a fiddle demonstrating the problem.](http://jsfiddle.net/jbeall/SqcjF/1/) – Josh Nov 09 '12 at 19:54
  • @JoshuaBeall Damn, I didn't test that. It's not working because the boolean value has no display counterpart in the dropdown's list of options. I'm working on an update, it might not be until tomorrow though. – Kyeotic Nov 09 '12 at 20:51
  • Thanks for the feedback and exploring some different options, Tyrsius! – Josh Nov 12 '12 at 16:47