Search code examples
knockout.jsjquery-ui-sortableknockout-sortableknockout-templatingknockout-binding-handlers

Two-dimensional knockout sortable not updating UI


I'm creating a two-dimensional sortable container with first dimension (rows in table) and second dimension (cells in a row).

The cells should be draggable within a row, to existing rows, to new rows created dynamically. Empty rows should be dynamically deleted. The cells are configured to occupy all space in a row.

How to edit the custom Knockout sortable binding (e.g. update event)?

Before:

Before

After:

After

Update problems:

  • When dragging a cell (.sortable-cell) to a new row (.sortable-table/.sortable-row) the viewModel gets updated, but not the UI
  • The placeholder (.highlight-horizontal) is not displayed, when dragging cell (.sortable-cell) to a new row (.sortable-table/.sortable-row)

//connect items with observableArrays
ko.bindingHandlers.sortableList = {
  init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
    $(element).data("sortList", valueAccessor().data); //attach meta-data
    $(element).sortable({
      placeholder: valueAccessor().placeholder,
      start: function(event, ui) {},
      change: function(event, ui) {},
      update: function(event, ui) {
        var item = ui.item.data("sortItem");
        if (item) {
          //identify parents
          var originalParent = ui.item.data("parentList");
          var newParent = ui.item.parent().data("sortList");
          //identify viewModel
          var viewModel = bindingContext.$root;
          //figure out its new position
          var position = ko.utils.arrayIndexOf(ui.item.parent().children(), ui.item[0]);

          if (ui.item.parent()[0].classList.contains("sortable-row")) {
            //Row already exists
            console.log("true");
          } else {
            //Row doesn't exist, create new row (PROBLEM WITH UPDATE HERE)
            newParent().splice(position, 0, {
              "children": ko.observableArray([])
            });
            newParent = newParent()[position].children;
          }

          //Update item position
          originalParent.remove(item);
          newParent.splice(position, 0, item);
          
          //Remove empty lists
          var children = viewModel.children();
          for (var i = 0; i < children.length; i++) {
            if (children[i].children().length == 0) {
              viewModel.children.remove(children[i]);
              console.log(children);
            }
          }

          //Update UI
          ui.item.remove();

          //Debug data model
          console.log("final viewModel");
          var children = viewModel.children();
          for (var i = 0; i < children.length; i++) {
            console.log(children[i].children());
            for (var j = 0; j < children[i].children().length; j++) { 
            	console.log(children[i].children()[j].children(),children[i].children()[j].content());
            }
          }
        }

      },
      connectWith: '.sortable-container'
    });
  }
};
//attach meta-data
ko.bindingHandlers.sortableItem = {
  init: function(element, valueAccessor) {
    var options = valueAccessor();
    $(element).data("sortItem", options.item);
    $(element).data("parentList", options.parentList);
  }
};
var self = this;
var viewModel = function() {
  var self = this;
  self.children = ko.observableArray(
    [{
      "children": ko.observableArray([{
        "content": ko.observable("Item 1"),
        "children": ko.observableArray([])
      }, {
        "content": ko.observable("Item 2"),
        "children": ko.observableArray([])
      }, {
        "content": ko.observable("Item 3"),
        "children": ko.observableArray([])
      }])
    }, {
      "children": ko.observableArray([{
        "content": ko.observable("Item 4"),
        "children": ko.observableArray([])
      }])
    }, {
      "children": ko.observableArray([{
        "content": ko.observable("Item 5"),
        "children": ko.observableArray([])
      }, {
        "content": ko.observable("Item 6"),
        "children": ko.observableArray([])
      }])
    }]
  );
};
ko.applyBindings(new viewModel());
.sortable-table {
  border: 1px red solid;
  padding: 10px 0px;
  list-style-type: none;
  width: 100% !important;
  display: table !important;
}
.sortable-table .sortable-row {
  height: 100% !important;
  display: table-row !important;
  padding: 5px 0px;
}
.sortable-table .sortable-cell {
  border: 1px solid green;
  display: table-cell !important;
  cursor: move;
}
.sortable-table .sortable-cell p {
  display: inline;
  margin: 0 !important;
}
.sortable-table .highlight-vertical {
  width: 5px !important;
  display: table-cell !important;
  background-color: blue !important;
}
.sortable-table .highlight-horizontal {
  height: 5px !important;
  width: 100% !important;
  display: block !important;
  background-color: blue !important;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://rniemeyer.github.com/KnockMeOut/Scripts/jquery.tmpl.js"></script>
<script src="https://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/2.2.1/knockout-min.js"></script>

<div class="sortable-container" data-bind="template: { name: 'rowTmpl', foreach: $data.children, templateOptions: { parentList: $data.children } }, sortableList: { data: $data.children, placeholder: 'highlight-horizontal' }">
</div>

<script id="rowTmpl" type="text/html">
  <div class="sortable-table">
    <div class="sortable-row sortable-container" data-bind="template: { name: 'cellTmpl', foreach: $data.children, templateOptions: { parentList: $data.children } }, sortableList: { data: $data.children, placeholder: 'highlight-vertical' }">
    </div>
  </div>
</script>

<script id="cellTmpl" type="text/html">
  <div class="sortable-cell" data-bind="sortableItem: { item: $data, parentList: $item.parentList }">
    <p data-bind="text: $data.content"></p>
  </div>
</script>


Solution

  • The problem was on line newParent.splice(position, 0, {"children": ko.observableArray([])});. newParent was called as newParent(), which was causing the problem.

    //connect items with observableArrays
    ko.bindingHandlers.sortableList = {
      init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
        $(element).data("sortList", valueAccessor().data); //attach meta-data
        $(element).sortable({
          placeholder: valueAccessor().placeholder,
          start: function(event, ui) {},
          change: function(event, ui) {},
          update: function(event, ui) {
            var item = ui.item.data("sortItem");
            if (item) {
              //identify parents
              var originalParent = ui.item.data("parentList");
              var newParent = ui.item.parent().data("sortList");
              //identify viewModel
              var viewModel = bindingContext.$root;
              //figure out its new position
              var position = ko.utils.arrayIndexOf(ui.item.parent().children(), ui.item[0]);
    
              if (ui.item.parent()[0].classList.contains("sortable-row")) {
                //Row already exists
                console.log("true");
              } else {
                //Row doesn't exist, create new row (PROBLEM WITH UPDATE HERE)
                newParent.splice(position, 0, {
                  "children": ko.observableArray([])
                });
                newParent = newParent()[position].children;
              }
    
              //Update item position
              originalParent.remove(item);
              newParent.splice(position, 0, item);
              
              //Remove empty lists
              var children = viewModel.children();
              for (var i = 0; i < children.length; i++) {
                if (children[i].children().length == 0) {
                  viewModel.children.remove(children[i]);
                  console.log(children);
                }
              }
    
              //Update UI
              ui.item.remove();
    
              //Debug data model
              console.log("final viewModel");
              var children = viewModel.children();
              for (var i = 0; i < children.length; i++) {
                console.log(children[i].children());
                for (var j = 0; j < children[i].children().length; j++) { 
                	console.log(children[i].children()[j].children(),children[i].children()[j].content());
                }
              }
            }
    
          },
          connectWith: '.sortable-container'
        });
      }
    };
    //attach meta-data
    ko.bindingHandlers.sortableItem = {
      init: function(element, valueAccessor) {
        var options = valueAccessor();
        $(element).data("sortItem", options.item);
        $(element).data("parentList", options.parentList);
      }
    };
    var self = this;
    var viewModel = function() {
      var self = this;
      self.children = ko.observableArray(
        [{
          "children": ko.observableArray([{
            "content": ko.observable("Item 1"),
            "children": ko.observableArray([])
          }, {
            "content": ko.observable("Item 2"),
            "children": ko.observableArray([])
          }, {
            "content": ko.observable("Item 3"),
            "children": ko.observableArray([])
          }])
        }, {
          "children": ko.observableArray([{
            "content": ko.observable("Item 4"),
            "children": ko.observableArray([])
          }])
        }, {
          "children": ko.observableArray([{
            "content": ko.observable("Item 5"),
            "children": ko.observableArray([])
          }, {
            "content": ko.observable("Item 6"),
            "children": ko.observableArray([])
          }])
        }]
      );
    };
    ko.applyBindings(new viewModel());
    .sortable-table {
      border: 1px red solid;
      padding: 10px 0px;
      list-style-type: none;
      width: 100% !important;
      display: table !important;
    }
    .sortable-table .sortable-row {
      height: 100% !important;
      display: table-row !important;
      padding: 5px 0px;
    }
    .sortable-table .sortable-cell {
      border: 1px solid green;
      display: table-cell !important;
      cursor: move;
    }
    .sortable-table .sortable-cell p {
      display: inline;
      margin: 0 !important;
    }
    .sortable-table .highlight-vertical {
      width: 5px !important;
      display: table-cell !important;
      background-color: blue !important;
    }
    .sortable-table .highlight-horizontal {
      height: 5px !important;
      width: 100% !important;
      display: block !important;
      background-color: blue !important;
    }
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <script src="https://rniemeyer.github.com/KnockMeOut/Scripts/jquery.tmpl.js"></script>
    <script src="https://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/2.2.1/knockout-min.js"></script>
    
    <div class="sortable-container" data-bind="template: { name: 'rowTmpl', foreach: $data.children, templateOptions: { parentList: $data.children } }, sortableList: { data: $data.children, placeholder: 'highlight-horizontal' }">
    </div>
    
    <script id="rowTmpl" type="text/html">
      <div class="sortable-table">
        <div class="sortable-row sortable-container" data-bind="template: { name: 'cellTmpl', foreach: $data.children, templateOptions: { parentList: $data.children } }, sortableList: { data: $data.children, placeholder: 'highlight-vertical' }">
        </div>
      </div>
    </script>
    
    <script id="cellTmpl" type="text/html">
      <div class="sortable-cell" data-bind="sortableItem: { item: $data, parentList: $item.parentList }">
        <p data-bind="text: $data.content"></p>
      </div>
    </script>