POST DIRECTORY
Category software development

In the previous article I covered installing Dragula.js in an Ember project and built a simple wrapper component I could use to test basic Ember integration. This article will extend the simple component to bridge Dragula’s DOM based view of the application with Ember components and models.

Building A Bridge

Let’s start with a recap of the simple-drag-and-drop component from the first article. The component has three responsibilities: starting and stopping a Dragula “drake” instance, registering DOM elements as drop “containers”, and sending a “dropped” action.

The direct child elements of containers are “draggable”. When a draggable is dropped on any container simple-drag-and-drop sends an action with DOM element arguments representing the draggable, target container, source container, and sibling draggable.

Our new drag-and-drop component should integrate with Ember components. It should be easy to use any existing component as a container or draggable. These components should be able to define their own draggable or container DOM element and supply an object (or Ember Data model) for the dropped action argument. Finally, drag-and-drop should support adding containers and draggables dynamically.

The drag-and-drop Component

These improvements are enabled by adding container and draggable registration abilities to the drag-and-drop component. This not only allows new container and draggable components to register with drag-and-drop, but they can register any object to be used as an argument when drag-and-drop sends the dropped action.

Here are the drag-and-drop component source files from an ember-cli project. The template simply yields the drag-and-drop component itself to be used in block form. Components in the block with access to drag-and-drop can register themselves as containers or draggables and specify an object or model to return in the dropped action.

<!-- app/templates/components/drag-and-drop.hbs -->
{{yield this}}
// app/components/drag-and-drop.js
import Ember from "ember";

export default Ember.Component.extend({
  registerContainer: function(containerEl, container) {
    this.get("containerRegistry").set(containerEl, container);
    this.get("drake").containers.push(containerEl);
  },
  registerDraggable: function(draggableEl, draggable) {
    this.get("draggableRegistry").set(draggableEl, draggable);
  },
  willInsertElement: function() {
    let drake = window.dragula();
    drake.on("drop", function(el, target, source, sibling) {
      this.sendAction(
        "dropped",
        this.get("draggableRegistry").get(el),
        this.get("containerRegistry").get(target),
        this.get("containerRegistry").get(source),
        this.get("draggableRegistry").get(sibling)
      );
    }.bind(this));
    this.set("drake", drake);
    this.set("containerRegistry", new Map());
    this.set("draggableRegistry", new Map());
  },
  willDestroyElement: function() {
    this.get("drake").destroy();
    this.set("drake", null);
    this.set("containerRegistry", null);
    this.set("draggableRegistry", null);
  }
});

An Example Using Cats in Queues

We start with Ember Data models representing cats, queues, and a pet store. The pet store has orderedQueues sorted by position, and queues have orderedCats sorted by position. The queue knows how to insert a cat before another in the orderedCats list, and it can compact orderedCats if one is removed.

// app/models/pet-store.js
import DS from "ember-data";

export default DS.Model.extend({
  queues: DS.hasMany("queue"),
  orderedQueues: function() {
    return this.get("queues").sortBy("position");
  }.property("[email protected]")
});
// app/models/queue.js
import DS from "ember-data";

export default DS.Model.extend({
  petStore: DS.belongsTo("pet-store"),
  position: DS.attr("number"),
  cats: DS.hasMany("cat"),
  orderedCats: function() {
    return this.get("cats").sortBy("position");
  }.property("[email protected]"),
  insertOrderedCat: function(cat, sibling) {
    // insert cat before sibling
  },
  compactOrderedCats: function() {
    // update cat positions to fill gaps
  }
});
// app/models/cat.js
import DS from "ember-data";

export default DS.Model.extend({
  name: DS.attr("string"),
  queue: DS.belongsTo("queue"),
  position: DS.attr("number")
});

The queues are checkout lanes in a pet store, here’s a pet store route and template. The route just loads a pet-store model by id. The template uses the drag-and-drop component in block form and passes it to each checkout-lane component in the loop.

// app/routes/pet-store.js
import Ember from 'ember';

export default Ember.Route.extend({
  model: function(params) {
    return this.store.findRecord("pet-store", params.id);
  }
});
<!-- app/templates/pet-store.hbs -->
<h1>Pet Store Checkout</h1>

{{#drag-and-drop dropped="moveCat" as |dnd|}}
  {{#each model.orderedQueues as |queue|}}
    {{checkout-lane queue=queue dnd=dnd}}
  {{/each}}
{{/drag-and-drop}}

The checkout-lane component registers its cat-list element as a container during setup using the passed dnd attribute. Its template loops over orderedCats and passes dnd to each queued-cat component.

// app/components/checkout-lane.js
import Ember from 'ember';

export default Ember.Component.extend({
  registerContainer: function() {
    this.dnd.registerContainer(this.$(".cat-list").get()[0], this.queue);
  }.on("didInsertElement")
});
<!-- app/templates/components/checkout-lane.hbs -->
<div class="cat-list">
  {{#each queue.orderedCats as |cat|}}
    {{queued-cat cat=cat dnd=dnd}}
  {{/each}}
</div>

The queued-cat component registers itself as a draggable during setup using the passed dnd attribute. The DOM element for a draggable is always this.element which represents the ember-view div that wraps the component template. This ember-view div will be a direct child of the cat-list container element as Dragula expects.

// app/components/queued-cat.js
import Ember from 'ember';

export default Ember.Component.extend({
  registerDraggable: function() {
    this.dnd.registerDraggable(this.element, this.cat);
  }.on("didInsertElement")
});
<!-- app/templates/components/queued-cat.hbs -->
<div>{{cat.name}}</div>

This handles all the container and draggable component registration, and we can now add queues and cats dynamically and be sure they register themselves properly. The last piece is adding a “moveCats” action handler to the pet-store controller, which is mapped to the drag-and-drop component “dropped” action. In this case the moveCats action handler inserts the dropped cat into the target queue before the sibling. It then compacts the source queue if it’s different than the target.

// app/controllers/pet-store.js
import Ember from 'ember';

export default Ember.Controller.extend({
  actions: {
    moveCats: function(cat, targetQueue, sourceQueue, siblingCat) {
      targetQueue.insertOrderedCat(cat, siblingCat).then(() => {
        if (targetQueue !== sourceQueue) {
          sourceQueue.compactOrderedCats();
        }
      });
    }
  }
});

This drag-and-drop component has worked well so far and provides a lot of flexibility without being too complicated. You can easily make existing components work as containers or draggables by having them register with a containing drag-and-drop component. In addition, the controller that handles the dropped action receives as arguments whatever model or object the containers and draggables choose during registration.

A Plot Twist! Ember and Dragula Duke It Out

As I was finishing my Dragula integration and feeling good about how everything was working, I stumbled across an interesting StackOverflow post from Ryan Hirsch. When using Dragula and Ember together, he reported that some drag and drop operations were reproducibly buggy and caused draggable component views to disappear from the DOM. When I tried these operations in my application I was disappointed to see the same bad behavior.

Check out his Ember Twiddle and follow the directions to see the problem in action.

Ember Twiddle – Simple DnD

I found the bug subtle and difficult to trace, but from Ember’s perspective the missing draggable component view is correctly rendered and inserted into the DOM. I tried to force Ember to rerender the missing view, but it didn’t recognize that the view was detached from the DOM and rerender did nothing. I also tried to force reloads on the related models, and while the models reloaded Ember still thought the related views were already correctly rendered and in the DOM.

An Uneasy Truce

I ended up using a somewhat ugly hack to trick Ember into repairing the views. My reply to the StackOverflow post describes my solution, basically I am using the Ember rendering queue to force the container element to hide then show its contents.

This solution sounds like it might cause visual flicker for the user, but the experience is actually very good. For the 99.9% of cases when the DOM is not corrupted, the user doesn’t see any artifacts of the rerendering at all. When something goes wrong and the DOM is out of sync, the rerendering will cause the missing element to pop back into place which is somewhat jarring but rare.

I expect this to be a potential problem (and something very difficult to debug or even detect in the first place) with any library that manipulates the DOM outside of Ember’s control. This makes me a little nervous about pulling in javascript libraries that aren’t explicitly Ember friendly. I got lucky stumbling across Ryan’s bug report, without it I wouldn’t have known about the problem until a user reported it.

For that matter there may still be other buggy sequences of drag and drop that have yet to be discovered. I applied my forced rerendering to both source and target containers in the real application, so I am hoping any other rendering bugs will be patched over. Unfortunately there is no good way to test every possible combination of drag and drop operations so I feel like there is always a (possibly small) vulnerability here.

For the time being, all the benefits of Dragula described in the first article still outweigh the ugliness of my forced rerendering trick. In the future I will be more careful when using libraries not written for Ember, especially anything that directly manipulates DOM elements and document structure.