Search code examples
javascriptknockout.jsbootstrap-multiselect

Bootstrap multiselect selectAll doesn't work with Knockout.js unless setTimeout is used


Apparently, Bootstrap multiselect has a limitation when using jQuery multiselect() with Knockout.js, so that if a multiselect dropdown is modified by code during a Knockout event (a click event, in the following example), then the code isn't applied.

The following example demonstrate it:

  1. First, click the button on the left. You'll see that although options are created, they are not selected. You'd need another click to make them selected.
  2. Then, click the button on the right. You'll see that options are both created and selected. I used a 1000 ms timeout, but it works with only 1 ms timeout as well.

My question: Is there a better way than a timeout to make selectAll() work?

var selectorVM = function () {
	var self = this;
  self.available = ko.observableArray([]);
  self.selected = ko.observableArray([]);
  self.init = function () {
    self.initOptions();
    self.selectAll();
  };
  self.initWithTimeout = function () {
    self.initOptions();
    self.selectAllWithTimeout();
  };
  self.initOptions = function () {
    self.available([]);
    self.available([
      { name: "option 1", value: 1}, 
      { name: "option 2", value: 2}
    ]);  
  };
  self.selectAll = function () {
      var $selector = $("#selector");
      $selector.multiselect('selectAll', false);
      $selector.multiselect('updateButtonText');
  };
  self.selectAllWithTimeout = function () {
    setTimeout(self.selectAll, 1000);
  };
}
var selectorVM = new selectorVM();
ko.applyBindings(selectorVM);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/js/bootstrap-multiselect.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/css/bootstrap-multiselect.css" rel="stylesheet"/>

<div> 
  <button class="btn btn-default" data-bind="click: init">Click to init dropdown, no timeout</button>
  <button class="btn btn-default" data-bind="click: initWithTimeout">Click to init dropdown, with timeout</button>
</div>
<div>
  <select id="selector" 
        class="form-control"
        multiple="multiple" 
        data-bind="options: available,
                   optionsText: 'name',
                   optionsValue: 'value',
                   selectedOptions: selected,
                   multiselect: { includeSelectAllOption: true }">
  </select>    
</div>


Solution

  • Your problem lies with code execution order. The built in KO options binding handler and the jQuery Multiselect plugin are both modifying the DOM, and the options handler probably executes after the multiselect function, so when you build the multiselect menu, there aren't yet any options for it to pick up. When you use setTimeout, even with a 1ms timeout, the function gets pushed to the end of the call stack and will execute when the current event loop has finished (see here for more details), so now it is executed after the options binding has finished. Ergo, it works.

    However, that's not very pretty. It's never a good idea to perform DOM manipulation inside your view model. That's why custom binding handlers are a thing; to easily interact with DOM elements and keep your view models clean. Your code references a multiselect binding handler, but I don't see it in your post. If we create it (it's not complicated), we'll see we can make the menu work as expected. Your original issue was you were using the supplied binding handler as well as manipulating the DOM manually. You should just stick to the binding handler and it'll work fine. No timeouts needed.

    With frameworks such as KO, as a general rule of thumb you should always only have to manipulate the data in your view model. KO should do the heavy lifting of updating the UI. Therefore, if you want to select all items, think about how this works: there's an observable array called selected where the ID's of the selected items are stored. So if you want to select all items, you only have to loop through all the available items, and push the ID's to the selected array. KO will take care of updating the UI for you. See the updated code snippet. I have created a separate button that calls the selectAll function, but you could of course just call selectAll in your init function if you wanted to.

    var selectorVM = function () {
      var self = this;
      
      self.available = ko.observableArray([]);
      self.selected = ko.observableArray([]);
      
      self.init = function () {
        self.initOptions();
      };
      
      self.initOptions = function () {
        self.available([
          { name: "option 1", value: 1 }, 
          { name: "option 2", value: 2 }
        ]);  
      };
    
      self.selectAll = function () {
        self.available().forEach(function (opt) {
          if (self.selected.indexOf(opt.value) === -1) {
            self.selected.push(opt.value);
          }
        });
      }
    }
    
    var selectorVM = new selectorVM();
    ko.applyBindings(selectorVM);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/js/bootstrap-multiselect.min.js"></script>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet"/>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/css/bootstrap-multiselect.css" rel="stylesheet"/>
    
    <div> 
      <button class="btn btn-default" data-bind="click: init">Click to init dropdown, no timeout</button>
      <button class="btn btn-default" data-bind="click: selectAll">Select all</button>
    </div>
    <div>
      <select id="selector" 
            class="form-control"
            multiple="multiple" 
            data-bind="options: available,
                       optionsText: 'name',
                       optionsValue: 'value',
                       selectedOptions: selected,
                       multiselect: { includeSelectAllOption: true }">
      </select>    
    </div>
    
    <p>Selected options in observable array:</p>
    <ul data-bind="foreach: selected"><li data-bind="text: $data"></li></ul>