Search code examples
knockout.jsknockout-mvcknockout-mvvmknockout-subscribe

Enforce Knockout computed observableArray to have length of 1 (one)


I have a computed observableArray called selectedToppings which returns filtered data from another observableArray called toppings. selectedToppings should return all toppings that have their observable property of selected set to true. In some situations, only one topping can be selected, such as if a customer is ordering the child-sized ice cream. Here is my code.

<label>Multiple Toppings:
    <input type="checkbox" data-bind="checked: multiselectable" />
    <em data-bind="if: !multiselectable()">pick only one topping</em>
</label>
<h2>Available</h2>
<ul data-bind="foreach: toppings">
    <li data-bind="text: label, click: selected.bind($data, true)"></li>
</ul>
<h2>Selected</h2>
<ul data-bind="foreach: selectedToppings">
    <li data-bind="text: label, click: selected.bind($data, false)">
    </li>
</ul>
<script>
function Topping(options) {
    this.label = ko.observable(options.label);
    this.selected = ko.observable(false);
}
var iceCream = {
    toppings: ko.observableArray([
        new Topping({label: 'Sprinkles'}),
        new Topping({label: 'Marshmallows'}),
        new Topping({label: 'Nuts'})
    ]),
    multiselectable: ko.observable(false)
};
iceCream.selectedToppings = ko.computed(function () {
    return ko.utils.arrayFilter(iceCream.toppings(), function(item){
        return item.selected();
    });
});
var selectedSub = iceCream.selectedToppings.subscribe(function (toppings) {
    if (!this.multiselectable()) {
        if (toppings.length > 1) {
            var item = toppings.shift();
            item.selected(false);
        }
    }
}, iceCream);
ko.applyBindings(iceCream);
</script>

Also on jsfiddle.

I am using a subscription on availableToppings to enforce the one-topping rule if it is supposed to be followed. This implementation is not working, and I am not sure how to fix it.

What is not working:

  1. Ensure multiselectable is not checked.
  2. Click on Available Sprinkles.
  3. Click on Available Marshmallows.
  4. Click on Available Nuts.
  5. Click on Available Sprinkles.
  6. Notice how the selection does not change.

Solution

  • It's usually difficult to manage data violations after it has already changed. In your example, it's easier and more reliable to prevent the user from making invalid selections in the first place, rather than trying to "fix it" after a bad entry is detected. A business rule should be applied later in the process to validate that the multiselection is honored, and throw a warning or exception if it's not, but the user interface can prevent most of these mistakes before they happen.

    Here's one way to prevent the invalid selection using the code you have shown: See the fiddle:

    This is in the view for the available toppings:

    <h2>Available</h2>
    <ul data-bind="foreach: toppings">
        <li data-bind="text: label, click: selected.bind($data, ($parent.multiselectable() || $parent.selectedToppings().length === 0) && true)"></li>
    </ul>
    

    With that in mind, I would consider a slightly different approach

    Take a look at this: JS Fiddle

    Think about separating out the list of available toppings from the ice cream itself. This will allow you to expand your example to include many ice cream instances without each instance needing to maintain a list of all the possible toppings and whether each one is selected or not.

    Instead, consider one list of toppings and let each ice cream object instance contains an array of only the toppings that have been added to that ice cream instance.

    function Topping(options) { this.label = ko.observable(options.label); }

    function IceCream(){
        this.toppings = ko.observableArray([]);
    };
    
    var toppings = ko.observableArray([
            new Topping({label: 'Sprinkles'}),
            new Topping({label: 'Marshmallows'}),
            new Topping({label: 'Nuts'})
        ]);
    var iceCream = new IceCream();
    var multiselectable = ko.observable(false);
    var viewModel = {
        multiselectable: multiselectable,
        iceCream: iceCream,
        toppings: toppings,
        addTopping: function(topping){
            if( multiselectable() || iceCream.toppings().length < 1)
                if( iceCream.toppings.indexOf(topping) == -1 )
                    iceCream.toppings.push(topping);
        },
        removeTopping: function(topping){
            iceCream.toppings.remove(topping);
        }
    };
    
    ko.applyBindings(viewModel);