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.
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.