Tutorial

Sortable lists with Phoenix LiveView and SortableJS

This post was updated 29 Nov - 2022

Phoenix 1.7 liveview sorting

A very common user interface pattern on the web is to have sortable elements. In this tutorial I will go through how to accomplish sortable lists with Phoenix LiveView and using a LiveView hook. However, you cant really do this without some external library. And for this, I will use the javascript library called SortableJS.

This tutorial uses a fresh installation of Phoenix 1.7 with LiveView. I have only added a Tasks CRUD.

I can brake it down into 3 steps

  1. Install SortableJS and add a LiveView Hook
  2. Implement the sortable list in LiveView template
  3. Persist and apply the sorting with Ecto

Step 1 - Install SortableJS and LiveView Hook

To get started,  go in to the assets folder and install sortablejs.

cd assets
yarn add sortablejs

This will create a new package json file and you are now able to use it in the application. And to use this with LiveView, the way to do it it with a hook. So, I need to create a new file and implement it. The LiveView hook will import and initialize Sortable with some basic configuration.

// assets/js/init_sorting.js
import Sortable from "sortablejs"

export const InitSorting = {
  mounted() {
    new Sortable(this.el, {
      animation: 150,
      ghostClass: "bg-yellow-100",
      dragClass: "shadow-2xl",
    })
  }
}

Note, this.el is the element in the DOM where the hook is attached.

Next step here is to import the hook and attach it to LiveView. Since this is a new project, the hooks is not setup so I need to make some changes and add this:

// assets/app.js
import {InitSorting} from "./init_sorting"

let Hooks = {}
Hooks.InitSorting = InitSorting

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})

Note that I added  hooks: Hooks in the end.

Step 2 - Add the sortable list in the view

I have already generated a LiveView CRUD for the taska that I want to implement the SortableJS in. So, instead of the table, I can add this in:

<% # lib/tutorial_web/live/task_live/index.html.heex %>
<div id="sortable-list" phx-hook="InitSorting" class="mt-8 border rounded border-sky-100 shadow-sm">
  <div :for={task <- @tasks} id={"task-#{task.id}"} class="px-2 py-8 border-b bg-sky-50 border-sky-100">
    <%= task.name %>
  </div>
</div>

With everything in place, I should now be able to navigate to my view and test the interface.

However, if I refresh the page, I can see that the sorting is not persisted. That is the topic for next step.

Step 3 - Persist sorting

The first thing in this step is to add a callback for when the sorting in completed, meaning when you release the mouse. There is more than one callbacks but I want to add onEnd: (evt) => {}.

And in that function, I collect the ids from all the sortable elements and push that to the LiveView with  this.pushEvent("update-sorting", {ids: ids}).

// assets/js/init_sorting.js
import Sortable from 'sortablejs'

export const InitSorting = {
  mounted() {
    new Sortable(this.el, {
      animation: 150,
      ghostClass: "bg-yellow-100",
      dragClass: "shadow-2xl",
      onEnd: (evt) => {
        const elements = Array.from(this.el.children)
        const ids = elements.map(elm => elm.id)
        this.pushEvent("update-sorting", {ids: ids})
      }
    })
  }
}

The only thing I need to do in the LiveView is to add an handle_event, parse out the ids and update them in order.

# lib/tutorial_web/live/task_live/index.ex
def handle_event("update-sorting", %{"ids" => ids}, socket) do
  ids
  |> Enum.with_index(1)
  |> Enum.each(fn {"task-" <> id, sort_order}->
    id
    |> Tasks.get_task!()
    |> Tasks.update_task(%{sort_order: sort_order})
  end)

  {:noreply, socket}
end

However, there is only two more things to do here.

  1. Make sure that list_tasks/0 returns the tasks in a sorted order
  2. When a task is created, add the correct sort order to it

To make sure the tasks are sorted correctly, update the function and just apply order_by

# lib/tutorial/tasks.ex
def list_tasks do
  Repo.all(from t in Task, order_by: [asc: :sort_order])
end

And when creating a task, I will just query the database and check for the highest existing sort order and then just increment with one.

# lib/tutorial/tasks.ex
def create_task(attrs \\ %{}) do
  %Task{}
  |> Task.changeset(attrs)
  |> Ecto.Changeset.put_change(:sort_order, get_next_sort_order(Task))
  |> Repo.insert()
end

defp get_next_sort_order(query) do
  existing_sort_order = Repo.aggregate((from q in query), :max, :sort_order) || 0
  existing_sort_order + 1
end

When I create the a new task, it should now get the correct sorting and end up last in the list.

Related Tutorials

Published 02 Sep - 2020

Table sorting with Ecto and LiveView

A very common or even mandatory feature in e-commerce stores is the ability to sort a list of products by attributes. This is easy enough and a good..

Published 18 Oct - 2021

Building a datatable in Phoenix LiveView

To display a static table on webpage that contains a lot of data is a pretty bad user experience. There are popular javascript libraries that implem..