Add show route for record sets

This commit is contained in:
Claudio Ortolina
2026-02-07 16:09:26 +00:00
parent 98a42a3762
commit 68956978e1
8 changed files with 420 additions and 2 deletions
+2 -1
View File
@@ -32,7 +32,8 @@
"Bash(sqlite3:*)",
"WebSearch",
"Bash(mix shellcheck:*)",
"Bash(mix compile:*)"
"Bash(mix compile:*)",
"mcp__tidewave__get_docs"
]
},
"enableAllProjectMcpServers": false
@@ -55,7 +55,9 @@
<div class="grow">
<header class="sm:flex items-baseline justify-start">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{record_set.name}
<.link navigate={~p"/record-sets/#{record_set}"} class="hover:underline">
{record_set.name}
</.link>
</h2>
<span class="sm:ml-2 text-xs text-zinc-500 dark:text-zinc-400">
{gettext("%{collected}/%{total} records", RecordSet.count_by_status(record_set))}
@@ -0,0 +1,81 @@
defmodule MusicLibraryWeb.RecordSetLive.Show do
use MusicLibraryWeb, :live_view
import MusicLibraryWeb.RecordComponents, only: [artist_links: 1]
alias MusicLibrary.RecordSets
alias MusicLibrary.RecordSets.RecordSet
alias MusicLibraryWeb.Markdown
alias Phoenix.LiveView.JS
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :current_section, :record_sets)}
end
@impl true
def handle_params(%{"id" => id}, _url, socket) do
record_set = RecordSets.get_record_set!(id)
{:noreply,
socket
|> assign(:record_set, record_set)
|> assign(:page_title, page_title(socket.assigns.live_action, record_set))}
end
@impl true
def handle_info(
{MusicLibraryWeb.RecordSetLive.Form, {:updated, record_set}},
socket
) do
{:noreply,
socket
|> assign(:record_set, record_set)
|> assign(:page_title, page_title(socket.assigns.live_action, record_set))}
end
def handle_info(
{MusicLibraryWeb.RecordSetLive.RecordPicker, {:added, record_set}},
socket
) do
{:noreply, assign(socket, :record_set, record_set)}
end
@impl true
def handle_event("delete_set", _params, socket) do
{:ok, _} = RecordSets.delete_record_set(socket.assigns.record_set)
{:noreply, push_navigate(socket, to: ~p"/record-sets")}
end
def handle_event("remove_record", %{"record-id" => record_id}, socket) do
{:ok, updated_set} =
RecordSets.remove_record_from_set(socket.assigns.record_set, record_id)
{:noreply, assign(socket, :record_set, updated_set)}
end
def handle_event("move_up", %{"record-id" => record_id}, socket) do
{:ok, updated_set} =
RecordSets.move_record_in_set(socket.assigns.record_set, record_id, :up)
{:noreply, assign(socket, :record_set, updated_set)}
end
def handle_event("move_down", %{"record-id" => record_id}, socket) do
{:ok, updated_set} =
RecordSets.move_record_in_set(socket.assigns.record_set, record_id, :down)
{: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
defp render_description(description) do
description
|> Markdown.to_html()
|> raw()
end
end
@@ -0,0 +1,186 @@
<Layouts.app flash={@flash} current_section={@current_section} socket={@socket}>
<div class="mt-4">
<header class="mt-4 flex items-baseline justify-between">
<div>
<h1 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{@record_set.name}
</h1>
<span class="text-xs text-zinc-500 dark:text-zinc-400">
{gettext("%{collected}/%{total} records", RecordSet.count_by_status(@record_set))}
</span>
</div>
<div class="flex items-center gap-2">
<.button
variant="solid"
size="sm"
patch={~p"/record-sets/#{@record_set}/show/add-record"}
>
<.icon name="hero-plus" class="icon" aria-hidden="true" data-slot="icon" />
{gettext("Add Record")}
</.button>
<.dropdown id="set-actions" placement="bottom-end">
<:toggle>
<.button variant="ghost">
<span class="sr-only">{gettext("Actions")}</span>
<.icon
name="hero-ellipsis-vertical"
class="h-5 w-5 text-zinc-500 dark:text-zinc-400 cursor-pointer"
aria-hidden="true"
data-slot="icon"
/>
</.button>
</:toggle>
<.dropdown_link
id="set-actions-edit"
patch={~p"/record-sets/#{@record_set}/show/edit"}
>
{gettext("Edit")}
</.dropdown_link>
<.separator />
<.dropdown_button
phx-click="delete_set"
data-confirm={gettext("Are you sure?")}
class={[
"text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!"
]}
>
{gettext("Delete")}
</.dropdown_button>
</.dropdown>
</div>
</header>
<article
:if={@record_set.description}
class="mt-4 prose dark:prose-invert prose-zinc prose-sm prose-h1:text-sm max-w-none"
>
{render_description(@record_set.description)}
</article>
<div class="mt-6">
<div
:if={@record_set.items == []}
id="no-records"
class="p-8 text-center bg-zinc-50 dark:bg-zinc-800 rounded-lg"
>
<.icon name="hero-musical-note" class="h-12 w-12 text-zinc-400 mx-auto mb-4" />
<p class="text-zinc-600 dark:text-zinc-400">
{gettext("No records in this set yet")}
</p>
</div>
<div
:if={@record_set.items != []}
class="grid grid-cols-2 sm:grid-cols-4 gap-4"
id="record-set-records"
>
<div
:for={item <- @record_set.items}
id={"record-#{item.record.id}"}
class={[
"group relative",
is_nil(item.record.purchased_at) &&
"opacity-60 hover:opacity-100 transition-opacity"
]}
>
<.link
:if={item.record.purchased_at}
navigate={~p"/collection/#{item.record}"}
>
<MusicLibraryWeb.RecordComponents.record_cover
record={item.record}
class="rounded-lg aspect-square object-cover"
width={512}
/>
</.link>
<.link
:if={!item.record.purchased_at}
navigate={~p"/wishlist/#{item.record}"}
>
<MusicLibraryWeb.RecordComponents.record_cover
record={item.record}
class="rounded-lg aspect-square object-cover"
width={512}
/>
</.link>
<div class="absolute top-1 right-1 rounded-full bg-zinc-100/50 hover:bg-zinc-100/75 dark:bg-zinc-700/50 dark:hover:bg-zinc-700/75 size-5">
<.dropdown
id={"item-actions-#{item.record.id}"}
placement="bottom-end"
>
<:toggle>
<span class="sr-only">{gettext("Actions")}</span>
<.icon
name="hero-ellipsis-vertical"
class="size-5 text-zinc-800 dark:text-zinc-200 cursor-pointer"
aria-hidden="true"
data-slot="icon"
/>
</:toggle>
<.dropdown_button
:if={item.position > 0}
phx-click="move_up"
phx-value-record-id={item.record.id}
>
{gettext("Move left")}
</.dropdown_button>
<.dropdown_button
:if={item.position < length(@record_set.items) - 1}
phx-click="move_down"
phx-value-record-id={item.record.id}
>
{gettext("Move right")}
</.dropdown_button>
<.separator />
<.dropdown_button
phx-click="remove_record"
phx-value-record-id={item.record.id}
data-confirm={gettext("Remove this record from the set?")}
class={[
"text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!"
]}
>
{gettext("Remove")}
</.dropdown_button>
</.dropdown>
</div>
<h1 class="mt-1 text-xs sm:text-sm leading-6 text-zinc-700">
<.artist_links joinphrase_class="text-xs" artists={item.record.artists} />
</h1>
<h2 class="flex font-semibold text-xs sm:text-sm leading-5 text-zinc-700 dark:text-zinc-300 text-wrap">
{item.record.title}
</h2>
</div>
</div>
</div>
</div>
<.structured_modal
:if={@live_action == :edit}
id="record-set-modal"
on_close={JS.patch(~p"/record-sets/#{@record_set}")}
>
<.live_component
module={MusicLibraryWeb.RecordSetLive.Form}
id={@record_set.id}
title={@page_title}
action={@live_action}
record_set={@record_set}
patch={~p"/record-sets/#{@record_set}"}
/>
</.structured_modal>
<.structured_modal
:if={@live_action == :add_record}
id="record-picker-modal"
on_close={JS.patch(~p"/record-sets/#{@record_set}")}
>
<.live_component
module={MusicLibraryWeb.RecordSetLive.RecordPicker}
id={"record-picker-#{@record_set.id}"}
title={@page_title}
record_set={@record_set}
patch={~p"/record-sets/#{@record_set}"}
/>
</.structured_modal>
</Layouts.app>
+4
View File
@@ -70,6 +70,10 @@ defmodule MusicLibraryWeb.Router do
live "/record-sets/:id/edit", RecordSetLive.Index, :edit
live "/record-sets/:id/add-record", RecordSetLive.Index, :add_record
live "/record-sets/:id", RecordSetLive.Show, :show
live "/record-sets/:id/show/edit", RecordSetLive.Show, :edit
live "/record-sets/:id/show/add-record", RecordSetLive.Show, :add_record
live "/scrobble-rules", ScrobbleRulesLive.Index, :index
live "/scrobble-rules/new", ScrobbleRulesLive.Index, :new
live "/scrobble-rules/:id/edit", ScrobbleRulesLive.Index, :edit
+17
View File
@@ -15,6 +15,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -40,6 +41,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -55,6 +57,8 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.ex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.ex
@@ -908,6 +912,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -1699,6 +1704,8 @@ msgid "Regenerate embeddings"
msgstr ""
#: 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/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Add Record"
msgstr ""
@@ -1714,11 +1721,13 @@ msgid "Edit Set"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Move left"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Move right"
msgstr ""
@@ -1765,11 +1774,13 @@ msgid "Record set updated successfully"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Remove this record from the set?"
msgstr ""
@@ -1800,6 +1811,7 @@ msgid "Search your records to add a record to this set."
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "%{collected}/%{total} records"
msgstr ""
@@ -1808,3 +1820,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Updated"
msgstr ""
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "No records in this set yet"
msgstr ""
+17
View File
@@ -15,6 +15,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -40,6 +41,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -55,6 +57,8 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.ex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.ex
@@ -908,6 +912,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -1699,6 +1704,8 @@ msgid "Regenerate embeddings"
msgstr ""
#: 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/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Add Record"
msgstr ""
@@ -1714,11 +1721,13 @@ msgid "Edit Set"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Move left"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Move right"
msgstr ""
@@ -1765,11 +1774,13 @@ msgid "Record set updated successfully"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Remove this record from the set?"
msgstr ""
@@ -1800,6 +1811,7 @@ msgid "Search your records to add a record to this set."
msgstr ""
#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "%{collected}/%{total} records"
msgstr ""
@@ -1808,3 +1820,8 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "Updated"
msgstr ""
#: lib/music_library_web/live/record_set_live/show.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "No records in this set yet"
msgstr ""
@@ -0,0 +1,110 @@
defmodule MusicLibraryWeb.RecordSetLive.ShowTest do
use MusicLibraryWeb.ConnCase
import MusicLibrary.Fixtures.RecordSets
import Phoenix.LiveViewTest
alias MusicLibrary.RecordSets
describe "Show" do
test "displays record set name", %{conn: conn} do
set = record_set(%{name: "My Favorites"})
conn
|> visit(~p"/record-sets/#{set}")
|> assert_has("h1", text: "My Favorites")
end
test "displays collected/total count", %{conn: conn} do
{set, _records} = record_set_with_records(2)
conn
|> visit(~p"/record-sets/#{set}")
|> assert_has("span", text: "2/2 records")
end
test "displays records in the set", %{conn: conn} do
{set, [r1, r2]} = record_set_with_records(2)
conn
|> visit(~p"/record-sets/#{set}")
|> assert_has("h2", text: escape(r1.title))
|> assert_has("h2", text: escape(r2.title))
end
test "shows empty state when set has no records", %{conn: conn} do
set = record_set()
conn
|> visit(~p"/record-sets/#{set}")
|> assert_has("p", text: "No records in this set yet")
end
test "renders markdown description as HTML", %{conn: conn} do
set = record_set(%{description: "This is **bold** text"})
conn
|> visit(~p"/record-sets/#{set}")
|> assert_has("article strong", text: "bold")
end
end
describe "Remove record" do
test "removes a record from the set", %{conn: conn} do
{set, [r1 | _]} = record_set_with_records(2)
{:ok, view, _html} = live(conn, ~p"/record-sets/#{set}")
view
|> element("button[phx-click='remove_record'][phx-value-record-id='#{r1.id}']")
|> render_click()
updated = RecordSets.get_record_set!(set.id)
refute Enum.any?(updated.items, &(&1.record.id == r1.id))
end
end
describe "Reorder records" do
test "moves a record right (down)", %{conn: conn} do
{set, [r1, r2 | _]} = record_set_with_records(3)
{:ok, view, _html} = live(conn, ~p"/record-sets/#{set}")
view
|> element("button[phx-click='move_down'][phx-value-record-id='#{r1.id}']")
|> render_click()
updated = RecordSets.get_record_set!(set.id)
ids_in_order = Enum.map(updated.items, & &1.record.id)
assert Enum.at(ids_in_order, 0) == r2.id
assert Enum.at(ids_in_order, 1) == r1.id
end
end
describe "Delete set" do
test "deletes set and navigates to index", %{conn: conn} do
set = record_set(%{name: "To Delete"})
{:ok, view, _html} = live(conn, ~p"/record-sets/#{set}")
view
|> element("button[phx-click='delete_set']")
|> render_click()
assert_redirect(view, ~p"/record-sets")
assert_raise Ecto.NoResultsError, fn -> RecordSets.get_record_set!(set.id) end
end
end
describe "Navigation from index" do
test "index links to show page", %{conn: conn} do
set = record_set(%{name: "Linked Set"})
conn
|> visit(~p"/record-sets")
|> click_link("Linked Set")
|> assert_has("h1", text: "Linked Set")
|> assert_path(~p"/record-sets/#{set}")
end
end
end