From e43bccb9bcd772b21723d4287e10595f7860277c Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Mon, 13 Apr 2026 23:09:22 +0100 Subject: [PATCH] Extract shared index logic into LiveHelpers.IndexActions Closes #150 --- docs/architecture.md | 1 + .../live/collection_live/index.ex | 139 ++++----------- .../live/wishlist_live/index.ex | 133 +++----------- .../live_helpers/index_actions.ex | 162 ++++++++++++++++++ priv/gettext/default.pot | 3 +- priv/gettext/en/LC_MESSAGES/default.po | 3 +- 6 files changed, 223 insertions(+), 218 deletions(-) create mode 100644 lib/music_library_web/live_helpers/index_actions.ex diff --git a/docs/architecture.md b/docs/architecture.md index cb6fc41d..fdd3a51d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -306,6 +306,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun | `Auth` | Authentication plugs: login password check, API token validation, session enforcement | | `ArtistLive.Biography` | Artist biography building/rendering from Wikipedia and Last.fm data | | `LiveHelpers.Params` | URL query param parsing: pagination, search query, sort order, display mode, fallback index | +| `LiveHelpers.IndexActions` | Shared index page logic (search, pagination, import, delete, display mode) for Collection/Wishlist index pages, parameterized by config map | | `LiveHelpers.RecordActions` | Shared record action handlers (refresh cover, genres, embeddings, MusicBrainz data) for Collection/Wishlist show pages | ### Controllers diff --git a/lib/music_library_web/live/collection_live/index.ex b/lib/music_library_web/live/collection_live/index.ex index 41718951..37b9e6aa 100644 --- a/lib/music_library_web/live/collection_live/index.ex +++ b/lib/music_library_web/live/collection_live/index.ex @@ -8,16 +8,24 @@ defmodule MusicLibraryWeb.CollectionLive.Index do alias MusicLibrary.Chats alias MusicLibrary.Collection - alias MusicLibrary.Records + alias MusicLibraryWeb.LiveHelpers.IndexActions - alias MusicLibraryWeb.ErrorMessages - - @default_records_list_params %{ - query: "", - page: 1, - page_size: 72, - order: :purchase - } + defp index_config do + %{ + context_module: Collection, + default_order: "purchase", + allowed_orders: [:purchase, :alphabetical, :release], + default_records_list_params: %{query: "", page: 1, page_size: 72, order: :purchase}, + purchased_at_fn: fn -> DateTime.utc_now() end, + import_page_title: gettext("Add new Record · Collection"), + section_page_title: gettext("Collection"), + import_success_toast: gettext("Record imported successfully"), + import_error_toast: gettext("Error importing record"), + record_path_fn: fn id -> ~p"/collection/#{id}" end, + index_path_fn: fn qs -> ~p"/collection?#{qs}" end, + base_index_path: ~p"/collection" + } + end @impl true def render(assigns) do @@ -234,6 +242,7 @@ defmodule MusicLibraryWeb.CollectionLive.Index do {:ok, socket |> assign(:current_section, :collection) + |> assign(:index_config, index_config()) |> assign(:import_query, "") |> assign(:display, :grid) |> assign(:open_chat, false) @@ -248,50 +257,31 @@ defmodule MusicLibraryWeb.CollectionLive.Index do end defp apply_action(socket, :import, params) do - import_query = params["import_query"] || "" - - socket - |> apply_fallback_index(params, :records, &apply_action/3) - |> assign(:page_title, gettext("Add new Record · Collection")) - |> assign(:import_query, import_query) - |> assign(:record, nil) + IndexActions.apply_import_action(socket, params) end defp apply_action(socket, :barcode_scan, params) do socket - |> apply_fallback_index(params, :records, &apply_action/3) + |> apply_fallback_index(params, :records, fn s, :index, p -> + IndexActions.apply_index_action(s, p) + end) |> assign(:page_title, gettext("Scan barcodes · Collection")) |> assign(:record, nil) end - defp apply_action(socket, :edit, %{"id" => id} = params) do - record = Records.get_record!(id) - - socket - |> apply_fallback_index(params, :records, &apply_action/3) - |> assign(:page_title, page_title(:edit, record)) - |> assign(:record, record) + defp apply_action(socket, :edit, params) do + IndexActions.apply_edit_action(socket, params) end defp apply_action(socket, :index, params) do - query = params["query"] || "" - order = parse_order(params["order"] || "purchase", [:purchase, :alphabetical, :release]) - total_records = Collection.search_records_count(query) - - record_list_params = - @default_records_list_params - |> merge_query(query) - |> merge_order(order) - |> merge_pagination(params, total_records) - socket - |> load_and_assign_records(record_list_params) + |> IndexActions.apply_index_action(params) |> assign(:open_chat, params["chat"] == "open") end @impl true def handle_info({MusicLibraryWeb.Components.RecordForm, {:saved, _record}}, socket) do - {:noreply, load_and_assign_records(socket, socket.assigns.record_list_params)} + IndexActions.handle_record_saved(socket) end def handle_info({MusicLibraryWeb.Components.Chat, :chats_changed}, socket) do @@ -310,71 +300,19 @@ defmodule MusicLibraryWeb.CollectionLive.Index do @impl true def handle_event("delete", %{"id" => id}, socket) do - record = Records.get_record!(id) - {:ok, _} = Records.delete_record(record) - - {:noreply, stream_delete(socket, :records, record)} + IndexActions.handle_delete(socket, id) end def handle_event("search", %{"query" => query}, socket) do - qs = - @default_records_list_params - |> Map.put(:query, query) - |> Map.take([:query, :page, :page_size]) - - {:noreply, push_patch(socket, to: ~p"/collection?#{qs}")} + IndexActions.handle_search(socket, query) end def handle_event("import", %{"id" => musicbrainz_id, "format" => format}, socket) do - current_time = DateTime.utc_now() - - case Records.import_from_musicbrainz_release_group(musicbrainz_id, - format: format, - purchased_at: current_time - ) do - {:ok, record} -> - {:noreply, - socket - |> put_toast(:info, gettext("Record imported successfully")) - |> push_navigate(to: ~p"/collection/#{record.id}")} - - {:error, reason} -> - {:noreply, - socket - |> put_toast( - :error, - gettext("Error importing record") <> ": " <> ErrorMessages.friendly_message(reason) - ) - |> push_patch(to: ~p"/collection")} - end + IndexActions.handle_import(socket, musicbrainz_id, format) end def handle_event("set_display", %{"mode" => mode}, socket) do - mode = parse_mode(mode) - - {:noreply, - socket - |> assign(:display, mode) - |> load_and_assign_records(socket.assigns.record_list_params)} - end - - defp load_and_assign_records(socket, record_list_params) do - offset = page_to_offset(record_list_params.page, record_list_params.page_size) - - opts = [ - limit: record_list_params.page_size, - offset: offset, - order: record_list_params.order - ] - - records = - Collection.search_records(record_list_params.query, opts) - - socket - |> assign(:page_title, gettext("Collection")) - |> assign(:record, nil) - |> assign(:record_list_params, record_list_params) - |> stream(:records, records, reset: true) + IndexActions.handle_set_display(socket, mode) end defp order_path(record_list_params, order) do @@ -395,21 +333,4 @@ defmodule MusicLibraryWeb.CollectionLive.Index do ~p"/collection?#{qs}" end - - defp page_title(action, record) do - Enum.join( - [ - Records.Record.artist_names(record), - "-", - record.title, - "·", - title_segment(action), - "·", - gettext("Collection") - ], - " " - ) - end - - defp title_segment(:edit), do: gettext("Edit") end diff --git a/lib/music_library_web/live/wishlist_live/index.ex b/lib/music_library_web/live/wishlist_live/index.ex index 7ba965a2..23c68c03 100644 --- a/lib/music_library_web/live/wishlist_live/index.ex +++ b/lib/music_library_web/live/wishlist_live/index.ex @@ -2,19 +2,28 @@ defmodule MusicLibraryWeb.WishlistLive.Index do use MusicLibraryWeb, :live_view import MusicLibraryWeb.Components.Pagination - import MusicLibraryWeb.LiveHelpers.Params import MusicLibraryWeb.RecordComponents alias MusicLibrary.Records alias MusicLibrary.Wishlist - alias MusicLibraryWeb.ErrorMessages + alias MusicLibraryWeb.LiveHelpers.IndexActions - @default_records_list_params %{ - query: "", - page: 1, - page_size: 72, - order: :alphabetical - } + defp index_config do + %{ + context_module: Wishlist, + default_order: "insertion", + allowed_orders: [:insertion, :alphabetical, :release], + default_records_list_params: %{query: "", page: 1, page_size: 72, order: :alphabetical}, + purchased_at_fn: fn -> nil end, + import_page_title: gettext("Add new Record · Wishlist"), + section_page_title: gettext("Wishlist"), + import_success_toast: gettext("Record wishlisted successfully"), + import_error_toast: gettext("Error wishlisting record"), + record_path_fn: fn id -> ~p"/wishlist/#{id}" end, + index_path_fn: fn qs -> ~p"/wishlist?#{qs}" end, + base_index_path: ~p"/wishlist" + } + end @impl true def render(assigns) do @@ -170,6 +179,7 @@ defmodule MusicLibraryWeb.WishlistLive.Index do {:ok, socket |> assign(current_section: :wishlist) + |> assign(:index_config, index_config()) |> assign(:import_query, "") |> assign(:display, :grid) |> assign(:current_date, current_date)} @@ -181,81 +191,33 @@ defmodule MusicLibraryWeb.WishlistLive.Index do end defp apply_action(socket, :import, params) do - import_query = params["import_query"] || "" - - socket - |> apply_fallback_index(params, :records, &apply_action/3) - |> assign(:page_title, gettext("Add new Record · Wishlist")) - |> assign(:import_query, import_query) - |> assign(:record, nil) + IndexActions.apply_import_action(socket, params) end - defp apply_action(socket, :edit, %{"id" => id} = params) do - record = Records.get_record!(id) - - socket - |> apply_fallback_index(params, :records, &apply_action/3) - |> assign(:page_title, page_title(:edit, record)) - |> assign(:record, record) + defp apply_action(socket, :edit, params) do + IndexActions.apply_edit_action(socket, params) end defp apply_action(socket, :index, params) do - query = params["query"] || "" - order = parse_order(params["order"] || "insertion", [:insertion, :alphabetical, :release]) - total_records = Wishlist.search_records_count(query) - - record_list_params = - @default_records_list_params - |> merge_query(query) - |> merge_order(order) - |> merge_pagination(params, total_records) - - load_and_assign_records(socket, record_list_params) + IndexActions.apply_index_action(socket, params) end @impl true def handle_info({MusicLibraryWeb.Components.RecordForm, {:saved, _record}}, socket) do - {:noreply, load_and_assign_records(socket, socket.assigns.record_list_params)} + IndexActions.handle_record_saved(socket) end @impl true - def handle_event("delete", %{"id" => id}, socket) do - record = Records.get_record!(id) - {:ok, _} = Records.delete_record(record) - - {:noreply, stream_delete(socket, :records, record)} + IndexActions.handle_delete(socket, id) end def handle_event("search", %{"query" => query}, socket) do - qs = - @default_records_list_params - |> Map.put(:query, query) - |> Map.take([:query, :page, :page_size]) - - {:noreply, push_patch(socket, to: ~p"/wishlist?#{qs}")} + IndexActions.handle_search(socket, query) end def handle_event("import", %{"id" => musicbrainz_id, "format" => format}, socket) do - case Records.import_from_musicbrainz_release_group(musicbrainz_id, - format: format, - purchased_at: nil - ) do - {:ok, record} -> - {:noreply, - socket - |> put_toast(:info, gettext("Record wishlisted successfully")) - |> push_navigate(to: ~p"/wishlist/#{record.id}")} - - {:error, reason} -> - {:noreply, - socket - |> put_toast( - :error, - gettext("Error wishlisting record") <> ": " <> ErrorMessages.friendly_message(reason) - ) - |> push_patch(to: ~p"/wishlist")} - end + IndexActions.handle_import(socket, musicbrainz_id, format) end def handle_event("add-to-collection", %{"id" => id}, socket) do @@ -275,29 +237,7 @@ defmodule MusicLibraryWeb.WishlistLive.Index do end def handle_event("set_display", %{"mode" => mode}, socket) do - mode = parse_mode(mode) - - {:noreply, - socket - |> assign(:display, mode) - |> load_and_assign_records(socket.assigns.record_list_params)} - end - - defp load_and_assign_records(socket, record_list_params) do - offset = page_to_offset(record_list_params.page, record_list_params.page_size) - - records = - Wishlist.search_records(record_list_params.query, - limit: record_list_params.page_size, - offset: offset, - order: record_list_params.order - ) - - socket - |> assign(:page_title, gettext("Wishlist")) - |> assign(:record, nil) - |> assign(:record_list_params, record_list_params) - |> stream(:records, records, reset: true) + IndexActions.handle_set_display(socket, mode) end defp order_path(record_list_params, order) do @@ -313,26 +253,9 @@ defmodule MusicLibraryWeb.WishlistLive.Index do defp back_path(record_list_params) do qs = record_list_params - |> Map.take([:query, :page, :page_size]) + |> Map.take([:query, :page, :page_size, :order]) |> Enum.filter(fn {_, v} -> v not in ["", nil] end) ~p"/wishlist?#{qs}" end - - defp page_title(action, record) do - Enum.join( - [ - Records.Record.artist_names(record), - "-", - record.title, - "·", - title_segment(action), - "·", - gettext("Wishlist") - ], - " " - ) - end - - defp title_segment(:edit), do: gettext("Edit") end diff --git a/lib/music_library_web/live_helpers/index_actions.ex b/lib/music_library_web/live_helpers/index_actions.ex new file mode 100644 index 00000000..d68d8417 --- /dev/null +++ b/lib/music_library_web/live_helpers/index_actions.ex @@ -0,0 +1,162 @@ +defmodule MusicLibraryWeb.LiveHelpers.IndexActions do + @moduledoc false + + import LiveToast, only: [put_toast: 3] + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, stream: 4, stream_delete: 3] + import MusicLibraryWeb.Components.Pagination, only: [page_to_offset: 2] + import MusicLibraryWeb.LiveHelpers.Params + + use Gettext, backend: MusicLibraryWeb.Gettext + + alias MusicLibrary.Records + alias MusicLibraryWeb.ErrorMessages + + @doc """ + Applies the :index action. Reads config from `socket.assigns.index_config` + for context_module, default_order, allowed_orders, and default_records_list_params. + + Returns the socket (not wrapped in `{:noreply, ...}`). + """ + def apply_index_action(socket, params) do + config = socket.assigns.index_config + + query = params["query"] || "" + order = parse_order(params["order"] || config.default_order, config.allowed_orders) + total_records = config.context_module.search_records_count(query) + + record_list_params = + config.default_records_list_params + |> merge_query(query) + |> merge_order(order) + |> merge_pagination(params, total_records) + + load_and_assign_records(socket, record_list_params) + end + + @doc """ + Applies the :import action. Falls back to :index if the stream hasn't + been initialized yet. + + Returns the socket (not wrapped in `{:noreply, ...}`). + """ + def apply_import_action(socket, params) do + config = socket.assigns.index_config + import_query = params["import_query"] || "" + + socket + |> apply_fallback_index(params, :records, fn s, :index, p -> apply_index_action(s, p) end) + |> assign(:page_title, config.import_page_title) + |> assign(:import_query, import_query) + |> assign(:record, nil) + end + + @doc """ + Applies the :edit action. Falls back to :index if the stream hasn't + been initialized yet. + + Returns the socket (not wrapped in `{:noreply, ...}`). + """ + def apply_edit_action(socket, %{"id" => id} = params) do + config = socket.assigns.index_config + record = Records.get_record!(id) + + socket + |> apply_fallback_index(params, :records, fn s, :index, p -> apply_index_action(s, p) end) + |> assign(:page_title, record_page_title(record, config)) + |> assign(:record, record) + end + + @doc """ + Loads records from the context module and assigns them to the socket stream. + + Returns the socket (not wrapped in `{:noreply, ...}`). + """ + def load_and_assign_records(socket, record_list_params) do + config = socket.assigns.index_config + offset = page_to_offset(record_list_params.page, record_list_params.page_size) + + records = + config.context_module.search_records(record_list_params.query, + limit: record_list_params.page_size, + offset: offset, + order: record_list_params.order + ) + + socket + |> assign(:page_title, config.section_page_title) + |> assign(:record, nil) + |> assign(:record_list_params, record_list_params) + |> stream(:records, records, reset: true) + end + + def handle_delete(socket, id) do + record = Records.get_record!(id) + {:ok, _} = Records.delete_record(record) + + {:noreply, stream_delete(socket, :records, record)} + end + + def handle_search(socket, query) do + config = socket.assigns.index_config + + qs = + config.default_records_list_params + |> Map.put(:query, query) + |> Map.take([:query, :page, :page_size]) + + {:noreply, push_patch(socket, to: config.index_path_fn.(qs))} + end + + def handle_import(socket, musicbrainz_id, format) do + config = socket.assigns.index_config + + case Records.import_from_musicbrainz_release_group(musicbrainz_id, + format: format, + purchased_at: config.purchased_at_fn.() + ) do + {:ok, record} -> + {:noreply, + socket + |> put_toast(:info, config.import_success_toast) + |> push_navigate(to: config.record_path_fn.(record.id))} + + {:error, reason} -> + {:noreply, + socket + |> put_toast( + :error, + config.import_error_toast <> ": " <> ErrorMessages.friendly_message(reason) + ) + |> push_patch(to: config.base_index_path)} + end + end + + def handle_set_display(socket, mode) do + mode = parse_mode(mode) + + {:noreply, + socket + |> assign(:display, mode) + |> load_and_assign_records(socket.assigns.record_list_params)} + end + + def handle_record_saved(socket) do + {:noreply, load_and_assign_records(socket, socket.assigns.record_list_params)} + end + + defp record_page_title(record, config) do + Enum.join( + [ + Records.Record.artist_names(record), + "-", + record.title, + "·", + gettext("Edit"), + "·", + config.section_page_title + ], + " " + ) + end +end diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 00e03026..815fd4a3 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -52,15 +52,14 @@ msgstr "" #: lib/music_library_web/components/notes.ex #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/artist_live/show.ex -#: lib/music_library_web/live/collection_live/index.ex #: lib/music_library_web/live/collection_live/show.ex #: lib/music_library_web/live/online_store_template_live/index.ex #: lib/music_library_web/live/record_set_live/index.ex #: lib/music_library_web/live/record_set_live/show.ex #: lib/music_library_web/live/scrobble_rules_live/index.ex #: lib/music_library_web/live/scrobbled_tracks_live/index.ex -#: lib/music_library_web/live/wishlist_live/index.ex #: lib/music_library_web/live/wishlist_live/show.ex +#: lib/music_library_web/live_helpers/index_actions.ex #, elixir-autogen, elixir-format msgid "Edit" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e2eb04de..25ce2a5f 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -52,15 +52,14 @@ msgstr "" #: lib/music_library_web/components/notes.ex #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/artist_live/show.ex -#: lib/music_library_web/live/collection_live/index.ex #: lib/music_library_web/live/collection_live/show.ex #: lib/music_library_web/live/online_store_template_live/index.ex #: lib/music_library_web/live/record_set_live/index.ex #: lib/music_library_web/live/record_set_live/show.ex #: lib/music_library_web/live/scrobble_rules_live/index.ex #: lib/music_library_web/live/scrobbled_tracks_live/index.ex -#: lib/music_library_web/live/wishlist_live/index.ex #: lib/music_library_web/live/wishlist_live/show.ex +#: lib/music_library_web/live_helpers/index_actions.ex #, elixir-autogen, elixir-format msgid "Edit" msgstr ""