Using SortableJS, skypack, Stimulus & fetch (without RailsUJS)

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)
  }
}
4 Likes

Thank you @danielvlopes that was super helpful.

I got one step closer. I managed to download and import lodash.debounce from skypack.

However, I’m failing at directly using a stimulus controller from skypack. I downloaded https://cdn.skypack.dev/-/stimulus-autocomplete@v2.0.0-xH1ZELrPRZ9LJ47tW5IP/dist=es2020,mode=imports/optimized/stimulus-autocomplete.js and tried the following:

import Autocomplete from "stimulus-autocomplete" // also tried with surrounding {}
application.register("autocomplete", Autocomplete)

but getting the following error in the browser:

Failed to autoload controller: autocomplete TypeError: constructor is undefined
    extendWithReflect http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1025
    shadow http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:969
    bless http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:965
    blessDefinition http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1052
    Module http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1059
    loadDefinition http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1357
    load http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1441
    load http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1441
    register http://localhost:3000/assets/stimulus/libraries/stimulus-3d2a9699cbbd56dd68eb11eb92e082378738d1463f4d1ebf31af116dc3660238.js:1434
    registerController http://localhost:3000/assets/stimulus/loaders/autoloader-049dda1957166e3d004afc916a4c399f9e164045c348d56d6fb01db375ef8acf.js:32
    loadController http://localhost:3000/assets/stimulus/loaders/autoloader-049dda1957166e3d004afc916a4c399f9e164045c348d56d6fb01db375ef8acf.js:21
    promise callback*loadController http://localhost:3000/assets/stimulus/loaders/autoloader-049dda1957166e3d004afc916a4c399f9e164045c348d56d6fb01db375ef8acf.js:21
    autoloadControllersWithin http://localhost:3000/assets/stimulus/loaders/autoloader-049dda1957166e3d004afc916a4c399f9e164045c348d56d6fb01db375ef8acf.js:8
    <anonymous> http://localhost:3000/assets/stimulus/loaders/autoloader-049dda1957166e3d004afc916a4c399f9e164045c348d56d6fb01db375ef8acf.js:52

I did replace the imports inside the js files from skypack paths to simply the package names which should work.

EDIT: perhaps this is because npm:stimulus-autocomplete | Skypack doesn’t have a check mark next to ES Module Entrypoint? I’m not a JS expert so I actually created ans issue - afcapel/stimulus-autocomplete/issues/52 - with them and they’re happy to get help adding that functionality (I could do it if someone can point me to similar work done elsewhere)

@danielvlopes I have a question about your fetch options.

You used credentials: "include"

According to here

Tells browsers to include credentials in both same- and cross-origin requests, and always use any credentials sent back in responses.

Does this not sound dangerous? You will send out your credentials data and allow back credentials data from somewhere else?

The default same-origin seems the better choice, no?

@danielvlopes Also it looks like your dataType: "script" is not part of the Fetch API but rather JQuery

that’s awesome and worked almost 100%:I had to modify - Rails returns 204, not 200 and the newIndex should be +1 to fit acts_as_list that is 1-based.