From fe748782c767f31a11d4e3c7225e84b912f21e45 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Tue, 10 Feb 2026 11:16:41 +0000 Subject: [PATCH] Reorder record sets via drag and drop --- assets/js/app.js | 2 + assets/js/hooks/sortable-list.js | 44 +++++++++++++++++++ assets/package-lock.json | 9 +++- assets/package.json | 3 +- lib/music_library/record_sets.ex | 16 +++++++ .../live/record_set_live/index.ex | 44 +++++++++++-------- .../live/record_set_live/index.html.heex | 28 +++++++++--- .../live/record_set_live/show.ex | 7 +++ .../live/record_set_live/show.html.heex | 13 ++++++ test/music_library/record_sets_test.exs | 20 +++++++++ .../live/record_set_live/index_test.exs | 17 +++++++ .../live/record_set_live/show_test.exs | 14 ++++++ 12 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 assets/js/hooks/sortable-list.js diff --git a/assets/js/app.js b/assets/js/app.js index fb152a58..13177c35 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,6 +26,7 @@ import { Hooks as FluxonHooks, DOM as FluxonDOM } from "fluxon"; import FormatNumberHook from "./hooks/format-number"; import UniversalSearchNavigationHook from "./hooks/universal-search-navigation"; import RecordPickerNavigationHook from "./hooks/record-picker-navigation"; +import SortableListHook from "./hooks/sortable-list"; import confetti from "canvas-confetti"; import { createLiveToastHook } from "live_toast"; import banner from "./banner"; @@ -34,6 +35,7 @@ let Hooks = FluxonHooks; Hooks.FormatNumber = FormatNumberHook; Hooks.UniversalSearchNavigation = UniversalSearchNavigationHook; Hooks.RecordPickerNavigation = RecordPickerNavigationHook; +Hooks.SortableList = SortableListHook; Hooks.LiveToast = createLiveToastHook(); const csrfToken = document diff --git a/assets/js/hooks/sortable-list.js b/assets/js/hooks/sortable-list.js new file mode 100644 index 00000000..4fa4a6e9 --- /dev/null +++ b/assets/js/hooks/sortable-list.js @@ -0,0 +1,44 @@ +import Sortable from "sortablejs"; + +export default { + mounted() { + this.initSortable(); + }, + + updated() { + if (this.sortable) { + this.sortable.destroy(); + } + this.initSortable(); + }, + + destroyed() { + if (this.sortable) { + this.sortable.destroy(); + } + }, + + initSortable() { + this.sortable = Sortable.create(this.el, { + animation: 150, + handle: "[data-sortable-handle]", + draggable: "[data-sortable-item]", + ghostClass: "opacity-30", + dragClass: "shadow-lg", + onEnd: () => { + const items = this.el.querySelectorAll("[data-sortable-item]"); + const recordIds = Array.from(items).map( + (item) => item.dataset.recordId + ); + + const payload = { record_ids: recordIds }; + const setId = this.el.dataset.setId; + if (setId) { + payload.set_id = setId; + } + + this.pushEvent("reorder", payload); + }, + }); + }, +}; diff --git a/assets/package-lock.json b/assets/package-lock.json index 14a5e208..7d58c49e 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "barcode-detector": "^3.0.0", "canvas-confetti": "^1.9.3", - "live_toast": "file:../deps/live_toast" + "live_toast": "file:../deps/live_toast", + "sortablejs": "^1.15.6" }, "devDependencies": { "@tailwindcss/typography": "^0.5.16" @@ -86,6 +87,12 @@ "node": ">=4" } }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", diff --git a/assets/package.json b/assets/package.json index b1823ca2..17bdbc26 100644 --- a/assets/package.json +++ b/assets/package.json @@ -2,7 +2,8 @@ "dependencies": { "barcode-detector": "^3.0.0", "canvas-confetti": "^1.9.3", - "live_toast": "file:../deps/live_toast" + "live_toast": "file:../deps/live_toast", + "sortablejs": "^1.15.6" }, "devDependencies": { "@tailwindcss/typography": "^0.5.16" diff --git a/lib/music_library/record_sets.ex b/lib/music_library/record_sets.ex index 0aee6110..5eda129d 100644 --- a/lib/music_library/record_sets.ex +++ b/lib/music_library/record_sets.ex @@ -116,6 +116,22 @@ defmodule MusicLibrary.RecordSets do {:ok, get_record_set!(record_set.id)} end + def reorder_records_in_set(%RecordSet{} = record_set, ordered_record_ids) + when is_list(ordered_record_ids) do + Repo.transaction(fn -> + ordered_record_ids + |> Enum.with_index() + |> Enum.each(fn {record_id, position} -> + from(i in RecordSetItem, + where: i.record_set_id == ^record_set.id and i.record_id == ^record_id + ) + |> Repo.update_all(set: [position: position]) + end) + end) + + {:ok, get_record_set!(record_set.id)} + end + def move_record_in_set(%RecordSet{} = record_set, record_id, direction) when direction in [:up, :down] do items = diff --git a/lib/music_library_web/live/record_set_live/index.ex b/lib/music_library_web/live/record_set_live/index.ex index 7f42e441..2d815fea 100644 --- a/lib/music_library_web/live/record_set_live/index.ex +++ b/lib/music_library_web/live/record_set_live/index.ex @@ -62,7 +62,7 @@ defmodule MusicLibraryWeb.RecordSetLive.Index do end defp apply_fallback_index(socket, params) do - if get_in(socket.assigns, [:streams, :record_sets]) == nil do + if socket.assigns[:record_sets] == nil do socket |> apply_action(:index, params) else @@ -84,7 +84,7 @@ defmodule MusicLibraryWeb.RecordSetLive.Index do |> assign(:list_params, list_params) |> assign(:page_title, gettext("Record Sets")) |> assign(:record_set, nil) - |> stream(:record_sets, sets, reset: true) + |> assign(:record_sets, sets) end def back_path(list_params) do @@ -98,29 +98,24 @@ defmodule MusicLibraryWeb.RecordSetLive.Index do @impl true def handle_info( - {MusicLibraryWeb.RecordSetLive.Form, {:created, record_set}}, + {MusicLibraryWeb.RecordSetLive.Form, {:created, _record_set}}, socket ) do - {:noreply, - socket - |> stream_insert(:record_sets, record_set, at: 0) - |> load_and_assign_sets(socket.assigns.list_params)} + {:noreply, load_and_assign_sets(socket, socket.assigns.list_params)} end def handle_info( {MusicLibraryWeb.RecordSetLive.Form, {:updated, record_set}}, socket ) do - {:noreply, stream_insert(socket, :record_sets, record_set)} + {:noreply, update_record_set_in_list(socket, record_set)} end def handle_info( {MusicLibraryWeb.RecordSetLive.RecordPicker, {:added, record_set}}, socket ) do - {:noreply, - socket - |> stream_insert(:record_sets, record_set)} + {:noreply, update_record_set_in_list(socket, record_set)} end @impl true @@ -138,31 +133,44 @@ defmodule MusicLibraryWeb.RecordSetLive.Index do record_set = RecordSets.get_record_set!(id) {:ok, _} = RecordSets.delete_record_set(record_set) - {:noreply, - socket - |> stream_delete(:record_sets, record_set) - |> load_and_assign_sets(socket.assigns.list_params)} + {:noreply, load_and_assign_sets(socket, socket.assigns.list_params)} end def handle_event("remove_record", %{"set-id" => set_id, "record-id" => record_id}, socket) do record_set = RecordSets.get_record_set!(set_id) {:ok, updated_set} = RecordSets.remove_record_from_set(record_set, record_id) - {:noreply, stream_insert(socket, :record_sets, updated_set)} + {:noreply, update_record_set_in_list(socket, updated_set)} end def handle_event("move_up", %{"set-id" => set_id, "record-id" => record_id}, socket) do record_set = RecordSets.get_record_set!(set_id) {:ok, updated_set} = RecordSets.move_record_in_set(record_set, record_id, :up) - {:noreply, stream_insert(socket, :record_sets, updated_set)} + {:noreply, update_record_set_in_list(socket, updated_set)} end def handle_event("move_down", %{"set-id" => set_id, "record-id" => record_id}, socket) do record_set = RecordSets.get_record_set!(set_id) {:ok, updated_set} = RecordSets.move_record_in_set(record_set, record_id, :down) - {:noreply, stream_insert(socket, :record_sets, updated_set)} + {:noreply, update_record_set_in_list(socket, updated_set)} + end + + def handle_event("reorder", %{"set_id" => set_id, "record_ids" => record_ids}, socket) do + record_set = RecordSets.get_record_set!(set_id) + {:ok, updated_set} = RecordSets.reorder_records_in_set(record_set, record_ids) + + {:noreply, update_record_set_in_list(socket, updated_set)} + end + + defp update_record_set_in_list(socket, updated_set) do + record_sets = + Enum.map(socket.assigns.record_sets, fn set -> + if set.id == updated_set.id, do: updated_set, else: set + end) + + assign(socket, :record_sets, record_sets) end defp parse_order("alphabetical"), do: :alphabetical diff --git a/lib/music_library_web/live/record_set_live/index.html.heex b/lib/music_library_web/live/record_set_live/index.html.heex index 99a4fcce..f8eb9921 100644 --- a/lib/music_library_web/live/record_set_live/index.html.heex +++ b/lib/music_library_web/live/record_set_live/index.html.heex @@ -35,10 +35,11 @@
-
-
+
+
<.dropdown id={"item-actions-#{record_set.id}-#{item.record.id}"} diff --git a/lib/music_library_web/live/record_set_live/show.ex b/lib/music_library_web/live/record_set_live/show.ex index 00fd237d..d2f61123 100644 --- a/lib/music_library_web/live/record_set_live/show.ex +++ b/lib/music_library_web/live/record_set_live/show.ex @@ -76,6 +76,13 @@ defmodule MusicLibraryWeb.RecordSetLive.Show do {:noreply, assign(socket, :record_set, updated_set)} end + def handle_event("reorder", %{"record_ids" => record_ids}, socket) do + {:ok, updated_set} = + RecordSets.reorder_records_in_set(socket.assigns.record_set, record_ids) + + {:noreply, assign(socket, :record_set, updated_set)} + end + defp page_title(:show, record_set), do: record_set.name defp page_title(:edit, record_set), do: gettext("Edit") <> " · " <> record_set.name defp page_title(:add_record, record_set), do: gettext("Add Record") <> " · " <> record_set.name diff --git a/lib/music_library_web/live/record_set_live/show.html.heex b/lib/music_library_web/live/record_set_live/show.html.heex index d7c52121..ccc70127 100644 --- a/lib/music_library_web/live/record_set_live/show.html.heex +++ b/lib/music_library_web/live/record_set_live/show.html.heex @@ -73,10 +73,13 @@ :if={@record_set.items != []} class="grid grid-cols-2 sm:grid-cols-4 gap-4" id="record-set-records" + phx-hook="SortableList" >
+
<.dropdown id={"item-actions-#{item.record.id}"} diff --git a/test/music_library/record_sets_test.exs b/test/music_library/record_sets_test.exs index c0c895d9..079b2367 100644 --- a/test/music_library/record_sets_test.exs +++ b/test/music_library/record_sets_test.exs @@ -176,6 +176,26 @@ defmodule MusicLibrary.RecordSetsTest do end end + describe "reorder_records_in_set/2" do + test "reorders records according to the given order" do + {set, [r1, r2, r3]} = record_set_with_records(3) + + {:ok, updated} = RecordSets.reorder_records_in_set(set, [r3.id, r1.id, r2.id]) + + ids_in_order = Enum.map(updated.items, & &1.record.id) + assert ids_in_order == [r3.id, r1.id, r2.id] + end + + test "no-ops when order is unchanged" do + {set, [r1, r2, r3]} = record_set_with_records(3) + + {:ok, updated} = RecordSets.reorder_records_in_set(set, [r1.id, r2.id, r3.id]) + + ids_in_order = Enum.map(updated.items, & &1.record.id) + assert ids_in_order == [r1.id, r2.id, r3.id] + end + end + describe "move_record_in_set/3" do test "moves a record up" do {set, [r1, r2 | _]} = record_set_with_records(3) diff --git a/test/music_library_web/live/record_set_live/index_test.exs b/test/music_library_web/live/record_set_live/index_test.exs index e15c4264..1efb0ac5 100644 --- a/test/music_library_web/live/record_set_live/index_test.exs +++ b/test/music_library_web/live/record_set_live/index_test.exs @@ -119,6 +119,23 @@ defmodule MusicLibraryWeb.RecordSetLive.IndexTest do end end + describe "Drag-and-drop reorder" do + test "reorders records via reorder event", %{conn: conn} do + {set, [r1, r2, r3]} = record_set_with_records(3) + + {:ok, view, _html} = live(conn, ~p"/record-sets") + + render_hook(view, "reorder", %{ + "set_id" => set.id, + "record_ids" => [r3.id, r1.id, r2.id] + }) + + updated = RecordSets.get_record_set!(set.id) + ids_in_order = Enum.map(updated.items, & &1.record.id) + assert ids_in_order == [r3.id, r1.id, r2.id] + end + end + describe "Reorder records" do test "moves a record left (up)", %{conn: conn} do {set, [r1, r2 | _]} = record_set_with_records(3) diff --git a/test/music_library_web/live/record_set_live/show_test.exs b/test/music_library_web/live/record_set_live/show_test.exs index aba0a55c..c61258e7 100644 --- a/test/music_library_web/live/record_set_live/show_test.exs +++ b/test/music_library_web/live/record_set_live/show_test.exs @@ -81,6 +81,20 @@ defmodule MusicLibraryWeb.RecordSetLive.ShowTest do end end + describe "Drag-and-drop reorder" do + test "reorders records via reorder event", %{conn: conn} do + {set, [r1, r2, r3]} = record_set_with_records(3) + + {:ok, view, _html} = live(conn, ~p"/record-sets/#{set}") + + render_hook(view, "reorder", %{"record_ids" => [r3.id, r1.id, r2.id]}) + + updated = RecordSets.get_record_set!(set.id) + ids_in_order = Enum.map(updated.items, & &1.record.id) + assert ids_in_order == [r3.id, r1.id, r2.id] + end + end + describe "Delete set" do test "deletes set and navigates to index", %{conn: conn} do set = record_set(%{name: "To Delete"})