Tuesday, August 30, 2016

knockout multiselect with checkbox

Knockout is very convenient library to use on html forms, and if we want to add a dropdown with multiselect checkboxes, then we have several options available:


Though using this custom binding seems very convenient, there are occasions when this binding tends to gets too slow to be used effectively in our site. For example with more than 2000 items in a dropdown with events subscribed to the selected items observable array. Also, grouping options proved to be somewhat more difficult than I thought initially.
These two look like promising solutions, but I was looking for something more lightweight.

So I resorted to use only the basic functions in the first binding and create a lightweight multiselect checkbox which can show grouped options. I will try to explain this approach here.

The Knockout Model

First I created a knockout view model for each option as:

var FilterOptionModel = function (text, value) {
            var self = this;
                        value = value ? value : "";
            self.Value = ko.observable(value);
                        text = text ? text : "";
            self.Text = ko.observable(text);
            self.IsSelected = ko.observable(false);
        };

This model contains a property IsSelected which is used to set the checkbox at each option level.

Next I added a view model for option groups like so:

var FilterGroupModel = function (label, options) {
            var self = this;
                        label = label ? label : "";
            self.Label = ko.observable(label);
                        options = options ? options : [];
            self.Options = ko.observableArray(options);
            self.IsSelected = ko.pureComputed({
                read: function () {
                    var nonSelected = false;
                    for (var i = 0; i < self.Options().length; i++) {
                        if (!self.Options()[i].IsSelected())
                            nonSelected = true;
                    }
                    return !nonSelected;
                },
                write: function (selected) {
                    for (var i = 0; i < self.Options().length; i++) {
                        !self.Options()[i].IsSelected(selected);
                    }
                }
            });
        };

 The group model also contains a computed observable IsSelected (which should be correctly named AreAllChildOptionsSelected). This observable returns true if all the children options have been selected.

And finally the view model for dropdown:
var FilterDropDownModel = function () {
            var self = this;
            self.Title = ko.observable('');
            self.Options = ko.observableArray([]);
            self.FlattenedOptions = ko.pureComputed(function () {
                var allOptions = self.Options();
                if (self.IsGroupedOptions()) {
                    allOptions = Array.prototype.concat.apply([], self.Options().map(function (category) { return category['Options'](); }));
                }
                return allOptions;
            }, self);
            self.IsGroupedOptions = ko.pureComputed({
                read: function () {
                    var result = false;
                    var allOptions = self.Options();
                    if (allOptions && allOptions.length && allOptions.length > 0 && allOptions[0].Options) {
                        result = true;
                    }
                    return result;
                }
            });
           
            self.SelectedOptions = ko.pureComputed({
                read: function () {
                    var result = self.FlattenedOptions().filter(function (option) { return option.IsSelected(); });
                    return result;
                },
                write: function (selectedOptions) {
                    if (selectedOptions && selectedOptions.length) {
                        for (var i = 0; i < self.Options().length; i++) {
                            if (self.IsGroupedOptions()) {
                                for (var j = 0; j < self.Options()[i].Options().length; j++) {
                                    var selectedOption = $.grep(selectedOptions, function (item) { return item.Value() === self.Options()[i].Options()[j].Value(); });
                                    if (selectedOption) {
                                        self.Options()[i].Options()[j].IsSelected(true);
                                    }
                                }
                            }
                            else {
                                var selectedOption = $.grep(selectedOptions, function (item) { return item.Value() === self.Options()[i].Value(); });
                                if (selectedOption) {
                                    self.Options()[i].IsSelected(true);
                                }
                            }
                        }
                    }
                    else {
                        for (var i = 0; i < self.Options().length; i++) {
                            self.Options()[i].IsSelected(false);
                        }
                    }
                }
            });

            self.IsAllOptionsSelected = ko.pureComputed({
                read: function () {
                    return self.FlattenedOptions().length === self.SelectedOptions().length;
                },
                write: function (selected) {
                    if (selected) {
                        self.SelectedOptions(self.FlattenedOptions());
                    }
                    else {
                        self.SelectedOptions([]);
                    }
                }
            });
            self.OptionsBusy = ko.observable(false);

            self.SelectedText = ko.pureComputed({
                read: function () {
                    if (self.SelectedOptions().length === 0) {
                        return 'None selected';
                    }
                    else if (self.FlattenedOptions().length === self.SelectedOptions().length) {
                        return 'All';
                    }
                    else if (self.SelectedOptions().length > 3) {
                        return self.SelectedOptions().length + ' selected';
                    }
                    else {
                        var selected = '';
                        var delimiter = ', ';
                        for (var i = 0; i < self.SelectedOptions().length; i++) {
                            var option = self.SelectedOptions()[i];
                            var label = option.Text();
                            selected += label + delimiter;
                        }

                        return selected.substr(0, selected.length - delimiter.length);
                    }
                }
            });
        };

Note that the drop down view model uses a computed observable named FlattenedOptions to make it easier to select/deselect options or groups. It also has another computed observable named SelectedOptions which sets/returns all the options (not groups) which currently have IsSelected set to true.

HTML

HTML page has knockout bindings which display all options and groups. It also has checkboxes which indecate if a group or an options is currently selected. To make it easier to reuse this component, I have separated the HTML knockout binding in a knockout template like so:

<div class="rounded-box" id="locationsSelect-container" data-bind="with: students">
  <span>There are </span><span data-bind="text: FlattenedOptions().length"></span><span> students in our class.</span>
  <br/>
    <span>There are </span><span data-bind="text: Options().length"></span><span> student groups in our class.</span>
<!-- ko template: { name: 'dropdown-template' } -->
<!-- /ko -->
</div>

<script type="text/html" id="dropdown-template">
 
        <div id="busyContainer" class="loading-div-mask" data-bind="css: { 'visible': OptionsBusy() }">
            <img class="loading-image" src="https://i0.wp.com/cdnjs.cloudflare.com/ajax/libs/galleriffic/2.0.1/css/loader.gif" alt="Loading..." />
        </div>
        <div class="multiselect-native-select">
            <div class="btn-group">
            <button type="button" class="multiselect dropdown-toggle btn btn-default" data-toggle="dropdown" title="All" aria-expanded="true"><span class="multiselect-selected-text" data-bind="text: SelectedText()"></span> <b class="caret"></b></button>
                <ul class="multiselect-container dropdown-menu">
                    <li class="multiselect-item multiselect-all"><a tabindex="0" class="multiselect-all"><label class="checkbox"><input type="checkbox" data-bind="checked: IsAllOptionsSelected">  Select all</label></a></li>
                    <!-- ko foreach: Options -->
                        <!-- ko if: typeof Options == 'undefined' -->
                            <li>
                                <a tabindex="0"><label class="checkbox">
                                    <input type="checkbox" data-bind="checkedValue: $data, checked: IsSelected"><span data-bind="text: Text"></span>
                                </label></a>
                            </li>
                        <!-- /ko -->
                        <!-- ko if: typeof Options != 'undefined' -->
                            <li class="multiselect-item multiselect-group">
                                <a tabindex="0"><label>
                                    <input type="checkbox" data-bind="checked: IsSelected"><span data-bind="text: Label"></span>
                                </label></a>
                               
                                    <!-- ko foreach: Options -->
                                    <li>
                                        <a tabindex="0"><label class="checkbox">
                                            <input type="checkbox" data-bind="checkedValue: $data, checked: IsSelected"><span data-bind="text: Text"></span>
                                        </label></a>
                                    </li>
                                    <!-- /ko -->
                               
                            </li>
                        <!-- /ko -->
                    <!-- /ko -->
                </ul>
            </div>
        </div>

</script>




The knockout object model and templates are all explained at https://jsfiddle.net/pranavnk/3ocwhyho/. The example uses jquery, bootstrap and knockout libraries.

No comments:

c# httpclient The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch

 If we get this error while trying to get http reponse using HttpClient object, it could mean that certificate validation fails for the remo...