- Prerequisiti
- Creazione del progetto
- Creazione del modello
- Creazione del componente LiveView
- Visualizzare la lista
- Aggiungere lo stato di completamento
- Modificare lo stato del completamento
- Creare un nuovo elemento
- Eliminare un elemento dalla lista
- Modificare un elemento
- Filtrare gli elementi della lista
- Modificare la posizione degli elementi
- Refactor - Riutilizzare i componenti
- PubSub
- Conclusioni
- Riferimenti esterni
Prerequisiti
Per questa guida è necessario aver installato sul proprio computer:
- Mix;
- Elixir;
- Phoenix;
- Postgres.
Creazione del progetto
Per iniziare creiamo il progetto utilizzando il comando:
mix phx.new todo_app --live
Con il parametro --live indichiamo di voler includere Phoenix.LiveView all'interno del progetto. Entriamo nella nuova cartella e seguiamo i passi indicati a fine installazione, creando il database e lanciando il server per verificare di aver eseguito correttamente i passi precedenti.
Creazione del modello
Le informazioni ricevute dall'utente saranno salvate all'interno del database.
Adesso creiamo la tabella dei Todo con una migrazione:
mix phx.gen.context Todos Todo todos title:string
Creazione del componente LiveView
In questa fase creeremo il componente che ci consentirà di visualizzare tutte le attività, crearne di nuove o di eliminarle.
Creiamo il nuovo file:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
use TodoAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
~L"""
<h1>Hello World!</h1>
"""
end
endModifichiamo il router nel modo seguente:
# scope "/", TodoAppWeb do pipe_through :browser # live "/", PageLive, :index live "/", TodoLive end
Ritornando sul browser dovreste vedere questa schermata:
Visualizzare la lista
Iniziamo con il creare qualche elemento da poter visualizzare in seguito. Eseguiamo dal terminale:
$ iex -S mix
> alias TodoApp.Todos
TodoApp.Todos
> Todos.create_todo(%{title: "First todo"})
[debug] QUERY OK db=4.0ms decode=1.4ms queue=2.0ms idle=652.3ms
INSERT INTO "todos" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["First todo", ~N[2021-07-09 10:45:00], ~N[2021-07-09 10:45:00]]
{:ok,
%TodoApp.Todos.Todo{
__meta__: #Ecto.Schema.Metadata<:loaded, "todos">,
id: 1,
inserted_at: ~N[2021-07-09 10:45:00],
title: "First todo",
updated_at: ~N[2021-07-09 10:45:00]
}}Modifichiamo il componente TodoLive nel modo seguente:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
use TodoAppWeb, :live_view
alias TodoApp.Todos
def mount(_params, _session, socket) do
{:ok, fetch(socket)}
end
def render(assigns) do
~L"""
<h1>Todo List</h1>
<ul>
<%= for todo <- @todos do %>
<li><%= todo.title %></li>
<% end %>
</ul>
"""
end
defp fetch(socket) do
assign(socket, %{todos: Todos.list_todos()})
end
endEsaminiamo in dettaglio le nuove modifiche:
- Con
aliasindichiamo di voler creare una scorciatoia per il moduloTodoApp.Todosin modo da scrivere solamenteTodos; - La funzione
fetchsi occupa di assegnare al socket una collezione che ha come chiavetodose come valore la lista di tutti gli elementi. In questo modo rendiamo disponibile all'interno della funzione render la variabile@todos.
Aggiungere lo stato di completamento
In questa fase vogliamo avere la possibilità di spuntare un elemento della lista per indicare che il compito è stato portato a termine.
Per prima cosa dovremmo aggiungere alla tabella dei Todo l'attributo completed.
$ mix ecto.gen.migration AddCompletedToTodos
Apriamo il nuovo file inserendo il nuovo attributo:
defmodule TodoApp.Repo.Migrations.AddCompletedToTodos do use Ecto.Migration def change do alter table(:todos) do add :completed, :boolean, default: false end end end
Eseguiamo la migrazione con il comando mix ecto.migrate. Successivamente dobbiamo modificare il changeset e lo schema di Todo:
# lib/todo_app/todos/todo.ex defmodule TodoApp.Todos.Todo do # ... schema "todos" do ... field :completed, :boolean ... end # ... def changeset(todo, attrs) do # ... |> cast(attrs, [:title, :completed]) # ... end end
Possiamo visualizzare all'interno del terminale il nuovo attributo:
$ iex -S mix
> alias TodoApp.Todos
TodoApp.Todos
> Todos.get_todo!(1)
[debug] QUERY OK source="todos" db=6.2ms idle=1626.5ms
SELECT t0."id", t0."title", t0."completed", t0."inserted_at", t0."updated_at" FROM "todos" AS t0 WHERE (t0."id" = $1) [1]
%TodoApp.Todos.Todo{
__meta__: #Ecto.Schema.Metadata<:loaded, "todos">,
completed: false,
id: 1,
inserted_at: ~N[2021-07-09 11:05:48],
title: "First todo",
updated_at: ~N[2021-07-09 11:05:48]
}Modificare lo stato del completamento
Adesso vogliamo visualizzare nell'app una checkbox per modificare il l'attributo completed del relativo todo. Modifichiamo il componente TodoLive nel modo seguente:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
...
def render(assigns) do
~L"""
<h1>Todo List</h1>
<ul>
<%= for todo <- @todos do %>
<li>
<%= content_tag :input,
nil,
type: 'checkbox',
phx_click: 'toggle-todo',
phx_value_todo_id: todo.id,
checked: todo.completed %>
<%= todo.title %>
</li>
<% end %>
</ul>
"""
end
def handle_event("toggle-todo", %{"todo-id" => id}, socket) do
todo = Todos.get_todo!(id)
{:ok, _} = Todos.update_todo(todo, %{completed: !todo.completed})
{:noreply, socket}
end
...
endAnalizziamo l'input della checkbox. Come possiamo osservare abbiamo aggiunto due attributi al nodo: phx_click e phx_value_todo_id. Il primo indica che al click dell'elemento dovrà essere richiamato l'handle toggle-todo. Il secondo sarà utilizzato per passare una Map con la chiave todo_id alla funzione handle_event con il valore dell'id del todo.
La funzione handle_event ha come parametri il nome dell'evento, una Map e il socket. In questo caso il compito di questo handle è quello di recuperare il todo e di aggiornare l'attributo completed.
Creare un nuovo elemento
In questa sezione introdurremo un form per creare un nuovo todo e visualizzarlo nella lista. Modifichiamo il componente TodoLive nel modo seguente:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
def render(assigns) do
~L"""
...
<%= form_for @changeset,
'#',
[
id: 'todo-form',
phx_submit: 'add-todo',
phx_change: 'validate'
], fn f -> %>
<%= text_input :todo,
:title,
placeholder: 'Create a todo' %>
<%= error_tag f, :title %>
<%= submit 'Add', phx_disable_with: 'Adding...' %>
<% end %>
...
"""
end
# ...
def handle_event("add-todo", %{"todo" => params}, socket) do
case Todos.create_todo(params) do
{:ok, _todo} ->
{:noreply, fetch(socket)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
def handle_event("validate", %{"todo" => params}, socket) do
changeset =
%TodoApp.Todos.Todo{}
|> Todos.change_todo(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, changeset: changeset)}
end
defp fetch(socket) do
socket
|> assign(:changeset, Todos.change_todo(%TodoApp.Todos.Todo{}))
|> assign(:todos, Todos.list_todos())
end
end
Notiamo che nel form abbiamo phx_submit e phx_change. Il primo richiama l'handle add-todo quando si preme sul submit, mentre viene richiamato l'handle validate ad ogni cambiamento del form.
L'add-todo provvede a creare il nuovo task in base ai parametri ricevuti dal form. Se la creazione è andata a buon fine viene aggiornato il socket con la lista di tutti i valori, incluso quello creato. In caso contrario viene assegnato al socket il changeset contenente gli errori. Ad esempio:
#Ecto.Changeset<action: :insert, changes: %{}, errors: [title: {"can't be blank", [validation: :required]}], data: #TodoApp.Todos.Todo<>, valid?: false>L'handle validate controlla che ad ogni cambiamento dell'input il valore sia corretto, cioè che non contenga una stringa vuota.
Eliminare un elemento dalla lista
Vogliamo aggiungere ad ogni elemento della lista il tasto per eliminarlo. Modifichiamo il componenente TodoLive nel modo seguente:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
...
def render(assigns) do
~L"""
...
<ul>
<%= for todo <- @todos do %>
<li>
...
<%= link 'Delete',
to: '#',
phx_click: 'delete-todo',
phx_value_todo_id: todo.id,
data: [confirm: 'Are you sure?'] %>
</li>
<% end %>
</ul>
...
"""
end
# ...
def handle_event("delete-todo", %{"todo-id" => id}, socket) do
todo = Todos.get_todo!(id)
{:ok, _} = Todos.delete_todo(todo)
{:noreply, fetch(socket)}
end
# ...
endAl click del link sarà richiamato l'handle delete-todo con il parametro todo.id. Di conseguenza verrà prelevato il task con il relativo id e cancellato dal database.
Modificare un elemento
In questa sezione vogliamo avere la possibilità di modificare un elemento cliccando sul tasto Edit e visualizzare l'input su un modale.
Per prima cosa creiamo un helper generico live_modal:
# lib/todo_app_web/live/live_helpers.ex defmodule TodoAppWeb.LiveHelpers do import Phoenix.LiveView.Helpers def live_modal(socket, component, opts) do path = Keyword.fetch!(opts, :return_to) modal_opts = [id: :modal, return_to: path, component: component, opts: opts] live_component(socket, TodoAppWeb.ModalComponent, modal_opts) end end
Includiamo il nuovo helper all'interno di view_helpers:
# lib/todo_app_web.ex defmodule TodoAppWeb do # ... defp view_helpers do quote do # ... import TodoAppWeb.LiveHelpers # ... end end # ... end
Creiamo il componente ModalComponent:
defmodule TodoAppWeb.ModalComponent do
use TodoAppWeb, :live_component
def render(assigns) do
~L"""
<div id='<%= @id %>' class='phx-modal'
phx-capture-click='close'
phx-window-keydown='close'
phx-key='escape'
phx-target='#<%= @id %>'
phx-page-loading
>
<div class='phx-modal-content'>
<%= live_patch raw('×'), to: @return_to, class: 'phx-modal-close' %>
<%= live_component @socket, @component, @opts %>
</div>
</div>
"""
end
def handle_event("close", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
endSuccessivamente creiamo il componente FormComponent
# lib/todo_app_web/live/form_component.ex
defmodule TodoAppWeb.FormComponent do
use TodoAppWeb, :live_component
alias TodoApp.Todos
def render(assigns) do
~L"""
<%= form_for @changeset,
'#',
[
id: 'todo-form',
phx_target: @myself,
phx_change: 'validate',
phx_submit: 'save'
], fn f -> %>
<%= text_input f,
:title,
placeholder: 'Create a todo' %>
<%= error_tag f, :title %>
<%= submit 'Save', phx_disable_with: 'Saving...' %>
<% end %>
"""
end
def update(%{todo: todo} = assigns, socket) do
changeset = Todos.change_todo(todo)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)}
end
def handle_event("validate", %{"todo" => params}, socket) do
changeset =
%TodoApp.Todos.Todo{}
|> Todos.change_todo(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("save", %{"todo" => todo_params}, socket) do
case Todos.update_todo(socket.assigns.todo, todo_params) do
{:ok, _todo} ->
{:noreply,
socket
|> put_flash(:info, "Todo updated successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
endAggiungiamo la funzione get_todo:
# lib/todo_app/todos.ex defmodule TodoApp.Todos do # ... def get_todo(id), do: Repo.get(Todo, id) # ... end
Modifichiamo il componente come segue:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
# ...
def render(assigns) do
~L"""
...
<ul>
<%= for todo <- @todos do %>
<li>
...
<%= live_patch 'Edit',
to: Routes.live_path(@socket, TodoAppWeb.TodoLive, %{edit: todo.id}) %>
</li>
<% end %>
</ul>
<%= if @show_edit_modal do %>
<%= live_modal @socket,
TodoAppWeb.FormComponent,
id: @todo.id,
title: 'Edit',
action: @live_action,
todo: @todo,
return_to: Routes.live_path(@socket, TodoAppWeb.TodoLive) %>
<% end %>
"""
end
# ...
def handle_params(%{"edit" => id}, _uri, socket) do
todo = Todos.get_todo(id)
case todo do
nil ->
{:noreply,
socket
|> put_flash(:info, "Todo not found")}
_ ->
{:noreply,
socket
|> assign(:show_edit_modal, true)
|> assign(:todo, todo)}
end
end
def handle_params(_params, _uri, socket) do
{:noreply, fetch(socket)}
end
# ...
defp fetch(socket) do
socket
|> assign(:changeset, Todos.change_todo(%TodoApp.Todos.Todo{}))
|> assign(:todos, Todos.list_todos())
|> assign(:show_edit_modal, false)
end
endFiltrare gli elementi della lista
In questa fase vogliamo filtrare gli elementi che sono stati completati.
Aggiungiamo a Todos la seguente query:
defmodule TodoApp.Todos do # ... def list_completed_todos do from(t in Todo, where: t.completed) |> Repo.all end # ... end
Modififichiamo il componente TodoLive:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
# ...
def render(assigns) do
~L"""
...
</ul>
<footer>
<%= live_patch 'All',
to: Routes.live_path(@socket, TodoAppWeb.TodoLive),
class: 'button' %>
<%= live_patch "Completed",
to: Routes.live_path(@socket, TodoAppWeb.TodoLive, %{filter: 'completed'}),
class: "button" %>
</footer>
<%= if @show_edit_modal do %>
...
<% end %>
"""
end
# ...
def handle_params(%{"filter" => filter}, _uri, socket) do
{:noreply,
socket
|> assign(:todos, Todos.list_completed_todos())
|> assign(:filter, filter)
}
end
def handle_params(_params, _uri, socket) do
{:noreply, fetch(socket)}
end
# ...
endSoffermiamoci sul secondo link live_patch "Completed" ....
Analizzando il DOM possiamo vedere che l'href generato è /filter=completed.
Quando viene reindirizzata la navigazione, il componente cattura eventuali parametri dal query dell'url richiamando la funzione handle_params. Nel nostro caso, quando abbiamo nei parametri filter, viene richiamata la funzione handle_params(%{"filter" => filter}, ...) che modifica la lista dei todos del socket inserendo solo quelli completati.
La seconda funzione handle_params(_params, _uri, socket) è quella di fallback nel caso in cui si ricevesse dal query un parametro non gestito.
Modificare la posizione degli elementi
Adesso vogliamo poter trascinare un elemento e cambiare la sua posizione. Per prima cosa dovremmo aggiungere alla tabella dei Todo l'attributo position.
$ mix ecto.gen.migration AddPositionToTodos
Modifichiamo il file della migrazione:
defmodule TodoApp.Repo.Migrations.AddPositionToTodos do use Ecto.Migration def change do alter table(:todos) do add :position, :integer, default: 0 end end end
Modifichiamo il changeset e lo schema:
# lib/todo_app/todos/todo.ex defmodule TodoApp.Todos.Todo do # ... schema "todos" do # ... field :position, :integer # ... end ... def changeset(todo, attrs) do # ... |> cast(attrs, [:title, :completed, :position]) # ... end end
Controlliamo dal terminale il nuovo attributo:
$ $ iex -S mix
> alias TodoApp.Todos
TodoApp.Todos
> Todos.get_todo!(1)
[debug] QUERY OK source="todos" db=17.6ms decode=1.1ms queue=2.0ms idle=1895.4ms
SELECT t0."id", t0."title", t0."completed", t0."position", t0."inserted_at", t0."updated_at" FROM "todos" AS t0 WHERE (t0."id" = $1) [1]
%TodoApp.Todos.Todo{
__meta__: #Ecto.Schema.Metadata<:loaded, "todos">,
completed: true,
id: 1,
inserted_at: ~N[2021-07-09 11:05:48],
position: 0,
title: "First todo modified",
updated_at: ~N[2021-07-09 15:37:53]
}Per poter eseguire il drag&drop abbiamo la necessità di installare la libreria sortablejs. Dal terminale:
$ cd assets && yarn add sortablejs && cd ..
Creiamo il seguente file:
// assets/js/init_sortable.js
import Sortable from "sortablejs"
export const InitSortable = {
mounted() {
const callback = list => {
this.pushEventTo(this.el.dataset.targetId, "sort", { list: list })
}
this.init(callback)
},
init(callback) {
const targetNode = this.el
const sortable = new Sortable(targetNode, {
onSort: evt => {
const nodeList = targetNode.querySelectorAll("[data-sortable-id]")
const list = [...nodeList].map((element, index) => (
{
id: element.dataset.sortableId,
position: index
}
))
callback(list)
}
})
}
}Modifichiamo il seguente file:
// assets/js/app.js
// ...
import {LiveSocket} from "phoenix_live_view"
import {InitSortable} from "./init_sortable"
// ...
let Hooks = {
InitSortable: InitSortable
}
// ...
// sostituiamo liveSocket con:
let liveSocket = new LiveSocket(
"/live",
Socket,
{
hooks: Hooks,
params: {_csrf_token: csrfToken}
}
)Modifichiamo la query dei todos in modo da prelevare gli elementi ordinati per posizione:
# lib/todo_app/todos.ex defmodule TodoApp.Todos do # ... def list_todos do Todo |> order_by(asc: :position) |> Repo.all() end # ... end
Modifichiamo il componente TodoLive:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
# ...
def render(assigns) do
~L"""
...
<ul phx-hook='InitSortable' id='items' data-target-id='#items'>
<%= for todo <- @todos do %>
<li data-sortable-id=<%=todo.id %>>
...
"""
end
# ...
def handle_event("sort", %{"list" => list}, socket) do
list
|> Enum.each(fn %{"id" => id, "position" => position} ->
Todos.get_todo!(id)
|> Todos.update_todo(%{"position" => position})
end)
{:noreply, socket}
end
# ...
endRefactor - Riutilizzare i componenti
Notiamo che il form e gli eventi all'interno del componente TodoLive sono pressocchè identici rispetto a quelli del componente FormComponent. Con qualche piccolo accorgimento possiamo riutilizzare quest'ultimo per comprire i casi di creazione o aggiornamento di un todo.
Per prima cosa modifichiamo il componente TodoLive nel modo seguente:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
# ...
def render(assigns) do
~L"""
<!-- ... -->
<!-- Eliminiamo il vecchio form ... -->
<!-- ... -->
<%= live_component @socket,
TodoAppWeb.FormComponent,
id: 'todo-form',
action: :new,
todo: @todo,
return_to: Routes.live_path(@socket, TodoAppWeb.TodoLive) %>
<!-- ... -->
<%= if @show_edit_modal do %>
<%= live_modal @socket,
TodoAppWeb.FormComponent,
id: @todo.id,
title: 'Edit',
action: :edit,
todo: @todo,
return_to: Routes.live_path(@socket, TodoAppWeb.TodoLive) %>
<% end %>
"""
end
# ...
# Eliminiamo l'handle 'add-todo' e 'validate'
# ...
# Modifichiamo la fetch
defp fetch(socket) do
# ...
|> assign(:todo, %TodoApp.Todos.Todo{})
# ...
end
endAll'interno del live_component abbiamo introdotto il nuovo parametro action per poter distinguere i casi di salvataggio di un nuovo elemento o il suo aggiornamento. Nel primo caso il todo sarà %TodoApp.Todos.Todo{}, ossia un todo vuoto. La sua inizializzazione avviene all'interno della funzione mount.
Adesso modifichiamo il componente TodoForm per poter gestire i due casi:
defmodule TodoAppWeb.FormComponent do
# ...
# Modifichiamo l'handle_event save
def handle_event("save", %{"todo" => todo_params}, socket) do
save_todo(socket, socket.assigns.action, todo_params)
end
defp save_todo(socket, :new, todo_params) do
case Todos.create_todo(todo_params) do
{:ok, _todo} ->
{:noreply,
socket
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
defp save_todo(socket, :edit, todo_params) do
case Todos.update_todo(socket.assigns.todo, todo_params) do
{:ok, _todo} ->
{:noreply,
socket
|> put_flash(:info, "Todo updated successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
endAll'interno degli assign del socket è presente il valore della action che ci aiuta a chiamare la funzione corretta che dovrà compiere l'azione di creazione o di aggiornamento.
PubSub
In questa fase vogliamo poter notificare a tutti gli utenti collegati eventuali cambiamenti nella lista dei todo.
Modifichiamo il file todos.ex nel modo seguente:
# lib/todo_app/todos.ex
defmodule TodoApp.Todos do
# ...
alias TodoApp.PubSub
@topic inspect(__MODULE__)
def subscribe do
Phoenix.PubSub.subscribe(PubSub, @topic)
end
# ...
def create_todo(attrs \\ %{}) do
# ...
|> notify({:todo, :created})
end
def update_todo(%Todo{} = todo, attrs) do
# ...
|> notify({:todo, :updated})
end
def delete_todo(%Todo{} = todo) do
# ...
|> notify({:todo, :deleted})
end
# ...
defp notify({:ok, result}, event) do
Phoenix.PubSub.broadcast(PubSub, @topic, {TodoApp.Todos, event, result})
{:ok, result}
end
defp notify({:error, reason}, _), do: {:error, reason}
endIl @topic contiene la stringa del nome del modulo, in questo caso "TodoApp.Todos". Nella funzione di notifica, utilizziamo la funzione broadcast per inviare un messaggio del tipo {:todo, <action>} a tutte le connessioni attive in quel momento.
Modifichiamo il componente TodoLive:
# lib/todo_app_web/live/todo_live.ex
defmodule TodoAppWeb.TodoLive do
# ...
def mount(_params, _session, socket) do
Todos.subscribe()
{:ok, fetch(socket)}
end
# ...
def handle_info({Todos, {:todo, action} = event, _result}, socket) do
{:noreply, assign(socket, :todos, Todos.list_todos())}
end
endQuando il componente riceve il messaggio di broadcast viene eseguito handle_info che si occuperà di aggiornare la lista dei todo. Ovviamente questa versione ha un piccolo difetto: cosa accade se un'utente elimina un elemento mentre qualcun'altro lo sta modificando? Il sistema tenta di aggiornare un elemento che non esiste, ricevendo un errore simile:
[debug] QUERY OK db=1.7ms queue=2.8ms idle=1152.4ms UPDATE "todos" SET "title" = $1, "updated_at" = $2 WHERE "id" = $3 ["prova", ~N[2021-07-16 10:17:20], 34] [error] GenServer #PID<0.611.0> terminating ** (Ecto.StaleEntryError) attempted to update a stale struct:
Conclusioni
In questo articolo abbiamo visto come creare una applicazione sfruttando i meccanismi di Phoenix LiveView. Inoltre abbiamo interagito con gli hook mostrando come far dialogare l'applicazione con una libreria Javascript, in questo caso sortablejs.
Osserviamo come l'applicazione non sia stata suddivisa in backend/frontend. LiveView si occupa di renderizzare il DOM, di aggiornarlo correttamente e di gestire gli eventi. In questo senso viene meno la funzione di un framework JS come ad esempio React: lo stato dell'applicazione si trova all'interno del processo del server.