We’ve been migrating https://knowyourteam.com to Turbo (and out of jQuery as well), and I was just migrating one of our features that requires drag-n-drop and it took me a bit longer than I expected to get it working. So I’m sharing the code here in case others might have similar needs. This covers:
- Downloading SortableJs library from skypack.dev as ESM.
- Wrapping a third-party lib with a Stimulus controller.
- Using fetch instead of Ajax.
- Not using Rails UJS
Our importmap.json (no changes)
{
"imports": {
"turbo": "<%= asset_path("turbo") %>",
<%= importmap_list_with_stimulus_from "app/assets/javascripts/controllers", "app/assets/javascripts/libraries" %>
}
}
Then we used skypack.dev to find the ESM version of sortablejs and saved it under app/assets/javascript/libraries. We could have used skypack directly but for now, we are avoiding external dependencies since we have our CDN and etc setup. You can find the ESM versions of npm packages by just visiting the final URL and grabbing the minified version. For SortableJS it’s https://cdn.skypack.dev/sortablejs@1.13.0?min and then grab the path of the import and save the file locally.
Final Stimulus controller:
// Drag-n-drop sortable
//
// Usage:
// We send the "position" param to the controller which will get passed to acts_as_list
//
// class Todo < ApplicationRecord
// acts_as_list top_of_list: 0
// end
//
// <div
// data-controller="sortable"
// data-sortable-animation-value="150"
// data-sortable-handle-value=".handle"
// data-sortable-resource-name-value="task"
// data-sortable-draggable-value=".task">
// <div class="task" data-sortable-update-url="<%= task_path(task) %>">
// <div class="handle">::</div>
// <div class="task-text">Task 1</div>
// </div>
// </div>
import { Controller } from "stimulus";
import { getMetaValue } from "helpers";
import Sortable from "sortablejs";
export default class extends Controller {
static values = {
resourceName: String,
animation: Number,
handle: String,
draggable: String,
};
initialize() {
this.onEnd = this.onEnd.bind(this);
this.onChoose = this.onChoose.bind(this);
}
connect() {
this.sortable = new Sortable(this.element, {
...this.defaultOptions,
...this.options,
});
this.originalOrder = this.sortable.toArray();
}
disconnect() {
this.sortable.destroy();
this.sortable = undefined;
}
onChoose(event) {
this.originalOrder = this.sortable.toArray();
}
onEnd({ item, newIndex, oldIndex }) {
let url = item.dataset.sortableUpdateUrl;
let originalOrder = this.originalOrder;
let sortable = this.sortable;
if (!url) return;
const resourceName = this.resourceNameValue;
const param = resourceName ? `${resourceName}[position]` : "position";
const formData = new FormData();
formData.append(param, newIndex);
fetch(url, {
body: formData,
method: "PATCH",
dataType: "script",
credentials: "include",
headers: {
"X-CSRF-Token": getMetaValue("csrf-token"),
},
}).then(function(response) {
if (response.status != 200) {
alert("Something went wrong. Please try again.");
sortable.sort(originalOrder, true);
}
})
}
get options() {
return {
animation: this.animationValue || this.defaultOptions.animation || 150,
handle: this.handleValue || this.defaultOptions.handle || undefined,
draggable: this.draggableValue || this.defaultOptions.draggable || undefined,
onEnd: this.onEnd,
onChoose: this.onChoose
};
}
get defaultOptions() {
return {};
}
}
Usually I’d prefer to not put the request inside the controller, and instead just have a form and use the Stimulus controller to trigger the form, but in this case I already have a bunch of forms in item and I also need something that would be an easy replacement for the jquery-ui sortable that we used before.
The helpers import where I’m getting the getMetaValue is just a small port of some of the helpers from Rails/UJS that I use often:
export function findElements(root, selector) {
if (typeof root == "string") {
selector = root
root = document
}
const elements = root.querySelectorAll(selector)
return toArray(elements)
}
export function findElement(root, selector) {
if (typeof root == "string") {
selector = root
root = document
}
return root.querySelector(selector)
}
export function getMetaValue(name) {
const element = findElement(document.head, `meta[name="${name}"]`)
if (element) {
return element.getAttribute("content")
}
}
export function toArray(value) {
if (Array.isArray(value)) {
return value
} else if (Array.from) {
return Array.from(value)
} else {
return [].slice.call(value)
}
}