Au 2.0RC: Mismatch error in the number of views of a repeat.for after external DOM manipulation (SortableJS)

Hello,

In my Au 1.0 app, I have my own implementation of drag-and-drop. In migrating to Au 2.0, I have decided to utilize SortableJS (vanilla).

It was easy enough to get up and running, and is actually working just fine. But not quite as I would like.

As you may know, SortableJS does, in fact, manipulate the DOM.

My list component takes in items and wraps them in a list node. Here is the full template of the list:

<import from="./tsi-list-columns"></import>
<import from="./tsi-list-item-node"></import>

<div
  ref="$list"
  class="tsi-list-items"
  select-all.trigger="onSelectAllChanged($event)"
  node-selected.trigger="onNodeSelectionChanged($event)"
>
  <tsi-list-columns
    columns.bind="columns"
    config.bind="config"
    select-all.bind="selectAll"
    column-clicked.trigger="onColumnClicked($event)"
  ></tsi-list-columns>

  <template repeat.for="node of nodes & signal:'refresh-list'; key.bind: node.id">
    <tsi-list-item-node
      class="tsi-list-item-node-wrapper"
      columns.bind="columns"
      selected.bind="selected"
      node.bind="node"
      config.bind="config"
      data-id.bind="node.item.id"
    ></tsi-list-item-node>
  </template>
</div>

If I do the following in the onEnd handler of Sortable, I get an Au error indicating a mismatch between the number of views and what was expected:

this.sortable = new Sortable(this.$list, {
  ...
  onEnd: (e) => {
    const node = this.nodes.splice(e.oldIndex - 1, 1)[0];
    this.nodes.splice(e.newIndex - 1, 0, node);
  },
});

But if do the splicing on the underlying items, and then re-wrap them, everything is fine:

this.sortable = new Sortable(this.$list, {
  ...
  onEnd: (e) => {
    const item = this.items.splice(e.oldIndex - 1, 1)[0];
    this.items.splice(e.newIndex - 1, 0, item);

    this._createNodes(this._defaultNodeVisitor);
  },
});

I would rather not have to re-wrap the items. That would be less than ideal on a larger list.

What I don’t understand is that I’m splicing and reinserting all in the same tick, so the number of underlying items is changing.

Even when I wrap the onEnd code in a queueAsyncTask or a setTimeout, I still have problems.

What is the guidance here for something like this?

For

If you just do this, then you’ll have mismatching between underlying model and the UI, i don’t think that is desired, but it shouldn’t cause any error, at least immediately like mentioned.

—

For

Shouldn’t you be able to slice and dice the nodes the same way you did with the items? Maybe add a batch in so that it does the processing only once

import { batch } from 'aurelia'

this.sortable = new Sortable(this.$list, {
  ...
  onEnd: (e) => {
    batch(() => {
      const item = this.items.splice(e.oldIndex - 1, 1)[0];
      this.items.splice(e.newIndex - 1, 0, item);

      const node = this.nodes.splice(e.oldIndex - 1, 1);
      this.nodes.splice(e.newIndex - 1, 0, node);
    });
  },
});

Unfortunately, your suggestions don’t solve the problem. Your batch code doesn’t agree with the logic of my application and it leads to other problems.

Working directly upon the nodes, but in a batch, does work. But eventually, sorting the list fails because the DOM and the model are out of sync. This eliminates the error, but fails logically eventually (my list eventually stops sorting when I click its column headings):

const node = this.nodes.splice(e.oldIndex - 1, 1)[0];
this.nodes.splice(e.newIndex - 1, 0, node);

This is the only approach that works (illustrated in my original post):

this.sortable = new Sortable(this.$list, {
  ...
  onEnd: (e) => {
    const item = this.items.splice(e.oldIndex - 1, 1)[0];
    this.items.splice(e.newIndex - 1, 0, item);

    this._createNodes(this._defaultNodeVisitor);
  },
});

Having to recreate the nodes is the clue here, I think: The DOM and the model are out of sync without this line, which is what causes my list to eventually stop sorting.

then maybe takeover the dom as well, it seems simple enough?

import { batch } from 'aurelia'

this.sortable = new Sortable(this.$list, {
  ...
  onEnd: (e) => {
    batch(() => {
      const item = this.items.splice(e.oldIndex - 1, 1)[0];
      this.items.splice(e.newIndex - 1, 0, item);

      const node = this.nodes.splice(e.oldIndex - 1, 1);
      this.nodes.splice(e.newIndex - 1, 0, node);

      const el = this.listWrapperEl.children[e.oldIndex - 1];
      const newSibling = this.listWrapperEl[e.newIndex - 1];
      this.listWrapperEl.insertBefore(el, newSibling);
    });
  },
});

For the reason changing the model stops the sort from working, maybe paste the full config of new Sortable( call? it seems weird that a dom only lib acts up when irrelevant model changes

Hello @bigopon,

I finally wrangled this and now have a working Aurelia 2.0 wrapper around SortableJS.

I must have missed batch in the documentation. But that feature is pretty cool, I have to say! And, yes, it was necessary.

This works just fine now (I no longer have to work with the underlying items and then re-wrap the items into nodes):

onDragEnd(e) {
  const oldIndex = e.detail.evt.oldIndex;
  const newIndex = e.detail.evt.newIndex;

  batch(() => {
    const node = this.nodes.splice(oldIndex, 1)[0];
    this.nodes.splice(newIndex, 0, node);
  });

  ...
}

So, for the benefit of the community, should someone want to utilize vanilla SortableJS in Aurelia, let me share a few observations and pain points:

  • The immediate child of a sortable instance should be a hard tag (div, ul, etc.), not the sortable items themselves;
  • Placing repeat.for on a template tag appears to be a problem, not so much with SortableJS, but in terms of destabilizing the DOM downstream;
  • Aurelia’s batch feature is absolutely essential for the the two splicing operations you will perform to manifest the DOM changes in the underling items on the model or viewModel;
  • The draggable selector should represent a hard tag above any custom component you might have representing your items (in other words, not a class or an id on the custom component tag, or even the custom component tag itself);
  • The handle selector shouldn’t be the same as the draggable selector (it appears that this causes confusion);
  • If you want animation, don’t forget to set animation on the sortable instance (the SortableJS documentation will lead you to believe that it defaults to 150, but it defaults to 0 instead);
  • Don’t use the default ghostClass or dragClass; define your own.

With respect to the first two bullets above, you should have something like this (I provide my wrapper below this code snippet):

<tsi-sortable
  direction="vertical"
  draggable=".tsi-list-item-node-frame"
  handle=".tsi-list-item-node"
  ghost-class="tsi-sortable-ghost-class"
  drag-class="tsi-sortable-drag-class"
  force-ballback.bind="true"
  fallback-on-body.bind="true"
  on-end.trigger="onDragEnd($event)"
>
  <div> <!-- <-- this div is necessary -->
    <div repeat.for="node of nodes; key.bind: node.id" class="tsi-list-item-node-frame">
      <tsi-list-item-node
        class="tsi-list-item-node-wrapper"
        columns.bind="columns"
        selected.bind="selected"
        node.bind="node"
        config.bind="config"
        data-id.bind="node.item.id"
      ></tsi-list-item-node>
    </div>
  </div>
</tsi-sortable>

I have thrown together an Aurelia wrapper around SortableJS. The below is a first pass at this. There are two files: tsi-sortable.js and tsi-sortable.html.

tsi-sortable.html:

<au-slot></au-slot>

tsi-sortable.js:

import { bindable, customElement, INode, resolve } from 'aurelia';

import { TsiCustomEventDispatcher } from 'common/library/TsiCustomEventDispatcher';

import { Sortable } from 'sortablejs';

@customElement('tsi-sortable')
export class TsiSortable {
  @bindable animation = 150;
  @bindable chosenClass = 'sortable-chosen';
  @bindable dataIdAttr = 'data-id';
  @bindable delay = 0;
  @bindable delayOnTouchOnly = false;
  @bindable direction = 'horizontal';
  @bindable disabled = false;
  @bindable dragClass = 'sortable-drag';
  @bindable draggable = null;
  @bindable dragoverBubble = false;
  @bindable easing = 'ease-in-out';
  @bindable emptyInsertThreshold = 5;
  @bindable fallbackClass = 'sortable-fallback';
  @bindable fallbackOnBody = false;
  @bindable fallbackTolerance = 0;
  @bindable filter = null;
  @bindable forceFallback = false;
  @bindable ghostClass = 'sortable-ghost';
  @bindable group = null;
  @bindable handle = null;
  @bindable invertedSwapThreshold = 1;
  @bindable invertSwap = false;
  @bindable preventOnFilter = true;
  @bindable removeCloneOnHide = true;
  @bindable revertOnSpill = true;
  @bindable snapBackOnAnimation = true;
  @bindable sort = true;
  @bindable store = null;
  @bindable swapThreshold = 1;
  @bindable touchStartThreshold = 0;

  @bindable target = undefined;
  @bindable includeOrderedArray = false;

  constructor() {
    this.$el = resolve(INode);
    this.eventDispatcher = resolve(TsiCustomEventDispatcher);
    this.sortable = undefined;

    this._excludedOptions = ['target', 'includeOrderedArray'];
    this._toArrayEvents = [
      'drag-ended',
      'item-unchosen',
      'item-added',
      'item-updated',
      'sort-changed',
      'item-removed',
      'item-changed',
    ];
  }

  attached() {
    // Query for the target if a selector is provided; otherwise, assume the first child
    const $target = this.target ? this.$el.querySelectorAll(this.target)?.[0] : this.$el.children?.[0];

    if (!$target) {
      throw new Error('No DOM element was found either as the first child or at the selector provided');
    }

    this.sortable = new Sortable($target, {
      animation: this.animation,
      chosenClass: this.chosenClass,
      dataIdAttr: this.dataIdAttr,
      delay: this.delay,
      delayOnTouchOnly: this.delayOnTouchOnly,
      direction: this.direction,
      disabled: this.disabled,
      dragClass: this.dragClass,
      draggable: this.draggable,
      dragoverBubble: this.dragoverBubble,
      easing: this.easing,
      emptyInsertThreshold: this.emptyInsertThreshold,
      fallbackClass: this.fallbackClass,
      fallbackOnBody: this.fallbackOnBody,
      fallbackTolerance: this.fallbackTolerance,
      filter: this.filter,
      forceFallback: this.forceFallback,
      ghostClass: this.ghostClass,
      group: this.group,
      handle: this.handle,
      invertedSwapThreshold: this.invertedSwapThreshold,
      invertSwap: this.invertSwap,
      preventOnFilter: this.preventOnFilter,
      removeCloneOnHide: this.removeCloneOnHide,
      revertOnSpill: this.revertOnSpill,
      snapBackOnAnimation: this.snapBackOnAnimation,
      sort: this.sort,
      store: this.store,
      swapThreshold: this.swapThreshold,
      touchStartThreshold: this.touchStartThreshold,
      setData: (dataTransfer, dragEl) => this.setData(dataTransfer, dragEl),
      onChoose: (e) => this.dispatchMappedEvent('item-chosen', e),
      onUnchoose: (e) => this.dispatchMappedEvent('item-unchosen', e),
      onStart: (e) => this.dispatchMappedEvent('drag-started', e),
      onEnd: (e) => this.dispatchMappedEvent('drag-ended', e),
      onAdd: (e) => this.dispatchMappedEvent('item-added', e),
      onUpdate: (e) => this.dispatchMappedEvent('sort-updated', e),
      onSort: (e) => this.dispatchMappedEvent('sort-changed', e),
      onRemove: (e) => this.dispatchMappedEvent('item-removed', e),
      onFilter: (e) => this.dispatchMappedEvent('item-filtered', e),
      onMove: (e) => this.dispatchMappedEvent('item-moved', e),
      onClone: (e) => this.dispatchMappedEvent('item-cloned', e),
      onChange: (e) => this.dispatchMappedEvent('item-changed', e),
    });
  }

  detaching() {
    this.sortable.destroy();
    this.sortable = undefined;
  }

  // eslint-disable-next-line no-unused-vars
  propertyChanged(option, newValue, oldValue) {
    if (!this.sortable || this._excludedOptions.includes(option)) return;
    this.sortable.option(option, newValue);
  }

  setData(dataTransfer, dragEl) {
    this.eventDispatcher.dispatch(this.$el, 'setData', { dataTransfer, dragEl });
  }

  dispatchMappedEvent(eventName, e) {
    let options = {};

    if (this.includeOrderedArray && this._toArrayEvents.includes(eventName)) {
      options = {
        domOrderedArray: this.sortable.toArray(),
      };
    }

    e.stopPropagation();
    this.eventDispatcher.dispatch(this.$el, eventName, { evt: e, ...options });
  }
}

Animated GIF of sorting working on my own custom list component:

SortableJS Wrapper

2 Likes