Tutorial
Sortable lists with Phoenix LiveView and SortableJS
This post was updated 29 Nov - 2022
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
- Install SortableJS and add a LiveView Hook
- Implement the sortable list in LiveView template
- 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.
- Make sure that
list_tasks/0
returns the tasks in a sorted order - 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.