Implement ML-143: Cart-style multi record import
Closes ML-143.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: ML-143
|
||||
title: Cart-style multi-record import in Add Record modal
|
||||
status: To Do
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-04-20 10:00'
|
||||
updated_date: '2026-04-20 10:01'
|
||||
updated_date: '2026-04-20 10:15'
|
||||
labels:
|
||||
- ui
|
||||
- liveview
|
||||
@@ -17,6 +17,7 @@ documentation:
|
||||
- backlog/ml-143/plan.md
|
||||
- backlog/ml-143/mockups.html
|
||||
priority: medium
|
||||
ordinal: 1000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
defmodule MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup do
|
||||
@moduledoc """
|
||||
Imports a record from a MusicBrainz release group in the background.
|
||||
|
||||
Used by the cart-style multi-record import when there are two or more
|
||||
records to import at once.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :music_brainz, max_attempts: 3
|
||||
|
||||
alias MusicLibrary.Records
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"release_group_id" => release_group_id} = args}) do
|
||||
opts = [
|
||||
format: args["format"],
|
||||
purchased_at: parse_datetime(args["purchased_at"])
|
||||
]
|
||||
|
||||
case Records.import_from_musicbrainz_release_group(release_group_id, opts) do
|
||||
{:ok, _record} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_datetime(nil), do: nil
|
||||
|
||||
defp parse_datetime(str) do
|
||||
{:ok, datetime, _offset} = DateTime.from_iso8601(str)
|
||||
datetime
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,16 @@
|
||||
defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
@moduledoc """
|
||||
Cart-style MusicBrainz import modal.
|
||||
|
||||
Users search MusicBrainz, stage `{release_group, format}` pairs into an ephemeral
|
||||
cart, then import them all at once. A single-item cart runs synchronously via
|
||||
`start_async`; two or more items enqueue one Oban job per item and close the modal.
|
||||
|
||||
The parent LiveView receives two messages and handles navigation/toasts:
|
||||
- `{__MODULE__, {:imported_single, record}}`
|
||||
- `{__MODULE__, {:imported_async, count}}`
|
||||
"""
|
||||
|
||||
use MusicLibraryWeb, :live_component
|
||||
|
||||
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
|
||||
@@ -6,20 +18,25 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
|
||||
alias MusicBrainz.ReleaseGroupSearchResult
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
||||
alias MusicLibraryWeb.ErrorMessages
|
||||
|
||||
require Logger
|
||||
|
||||
@batch_size 20
|
||||
@default_format :cd
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="w-86 md:w-2xl">
|
||||
<div class="grid grid-cols-1 md:grid-cols-5">
|
||||
<section class="md:col-span-3 p-4 md:border-r md:border-zinc-200 md:dark:border-zinc-800">
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id={:import_form}
|
||||
phx-target={@myself}
|
||||
phx-change="search"
|
||||
phx-submit="search"
|
||||
class="px-4"
|
||||
>
|
||||
<.input
|
||||
id={:mb_query}
|
||||
@@ -32,12 +49,11 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
autofocus
|
||||
/>
|
||||
</.simple_form>
|
||||
<.alert :if={@error_message} color="danger" hide_close class="mx-4 mt-4">
|
||||
<.alert :if={@error_message} color="danger" hide_close class="mt-4">
|
||||
{@error_message}
|
||||
</.alert>
|
||||
<ul
|
||||
id="release-groups"
|
||||
phx-update="stream"
|
||||
phx-viewport-bottom={!@loaded_all_results? && "load-more"}
|
||||
phx-target={@myself}
|
||||
role="list"
|
||||
@@ -47,8 +63,11 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
]}
|
||||
>
|
||||
<.result
|
||||
:for={{id, release_group} <- @streams.release_groups}
|
||||
id={id}
|
||||
:for={release_group <- @release_groups}
|
||||
id={"musicbrainz_#{release_group.id}"}
|
||||
myself={@myself}
|
||||
in_cart?={in_cart?(@cart_pairs, release_group.id)}
|
||||
cart_format={cart_format(@cart, release_group.id)}
|
||||
release_group={release_group}
|
||||
icon_name={@icon_name}
|
||||
/>
|
||||
@@ -61,6 +80,158 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
{gettext("No results")}
|
||||
</div>
|
||||
<.results_footer total_results={@release_groups_total_count} />
|
||||
</section>
|
||||
|
||||
<aside class={[
|
||||
"md:col-span-2 bg-zinc-50 dark:bg-zinc-900/60",
|
||||
"border-t md:border-t-0 md:border-l md:border-zinc-200 md:dark:border-zinc-800",
|
||||
"flex flex-col"
|
||||
]}>
|
||||
<div class="px-4 py-3 flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">
|
||||
{gettext("Cart")}
|
||||
</p>
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{ngettext("%{count} record", "%{count} records", length(@cart), count: length(@cart))}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
:if={@cart != []}
|
||||
type="button"
|
||||
phx-click="clear_cart"
|
||||
phx-target={@myself}
|
||||
class="text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
|
||||
>
|
||||
{gettext("Clear all")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_cart"
|
||||
phx-target={@myself}
|
||||
class="rounded-md p-1 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-800 md:hidden"
|
||||
aria-label={gettext("Toggle cart")}
|
||||
>
|
||||
<.icon
|
||||
name={if @cart_expanded?, do: "hero-chevron-down", else: "hero-chevron-up"}
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={["md:!block", not @cart_expanded? && "hidden"]}>
|
||||
<div
|
||||
:if={@cart == []}
|
||||
id="cart-empty"
|
||||
class="flex flex-col items-center justify-center gap-2 px-6 py-10 text-center"
|
||||
>
|
||||
<.icon
|
||||
name="hero-shopping-bag"
|
||||
class="size-8 text-zinc-400"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
/>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{gettext("Your cart is empty")}
|
||||
</p>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{gettext("Add records from the search results to get started.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
:if={@cart != []}
|
||||
id="cart-items"
|
||||
class="divide-y divide-zinc-200 dark:divide-zinc-800 md:max-h-[calc(100vh-20rem)] overflow-y-auto"
|
||||
>
|
||||
<li
|
||||
:for={item <- @cart}
|
||||
id={"cart-item-#{item.cart_item_id}"}
|
||||
class="flex gap-3 px-4 py-3"
|
||||
>
|
||||
<img
|
||||
class="w-12 h-12 rounded-md object-cover"
|
||||
alt={item.title}
|
||||
src={item.thumb_url}
|
||||
onerror={"this.src = '" <> ~p"/images/cover-not-found.png" <> "';"}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{item.artists}
|
||||
</p>
|
||||
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{item.title}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<form phx-change="change_format" phx-target={@myself}>
|
||||
<input type="hidden" name="cart_item_id" value={item.cart_item_id} />
|
||||
<select
|
||||
name="format"
|
||||
aria-label={gettext("Format")}
|
||||
class="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-1.5 py-0.5"
|
||||
>
|
||||
<option
|
||||
:for={format <- Records.Record.formats()}
|
||||
value={format}
|
||||
selected={format == item.format}
|
||||
>
|
||||
{format_label(format)}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_from_cart"
|
||||
phx-value-cart_item_id={item.cart_item_id}
|
||||
phx-target={@myself}
|
||||
class="text-xs text-zinc-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
{gettext("Remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
:if={@cart != []}
|
||||
class="border-t border-zinc-200 dark:border-zinc-800 px-4 py-3"
|
||||
>
|
||||
<.button
|
||||
variant="solid"
|
||||
phx-click="import_cart"
|
||||
phx-target={@myself}
|
||||
disabled={@importing?}
|
||||
class="w-full"
|
||||
>
|
||||
<.icon
|
||||
:if={@importing?}
|
||||
name="hero-arrow-path"
|
||||
class="icon animate-spin"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
/>
|
||||
<.icon
|
||||
:if={not @importing?}
|
||||
name="hero-plus"
|
||||
class="icon"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
/>
|
||||
{ngettext(
|
||||
"Import %{count} record",
|
||||
"Import %{count} records",
|
||||
length(@cart),
|
||||
count: length(@cart)
|
||||
)}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -68,6 +239,9 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
attr :id, :string, required: true
|
||||
attr :icon_name, :string, required: true
|
||||
attr :release_group, MusicBrainz.ReleaseGroupSearchResult, required: true
|
||||
attr :myself, :any, required: true
|
||||
attr :in_cart?, :boolean, required: true
|
||||
attr :cart_format, :atom, required: true
|
||||
|
||||
defp result(assigns) do
|
||||
~H"""
|
||||
@@ -93,9 +267,17 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
:if={@in_cart?}
|
||||
class="mr-2 inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5"
|
||||
>
|
||||
<.icon name="hero-check" class="size-3" aria-hidden="true" data-slot="icon" />
|
||||
{gettext("In cart · %{format}", format: format_label(@cart_format))}
|
||||
</span>
|
||||
|
||||
<.dropdown id={"actions-#{@release_group.id}"} placement="bottom-end">
|
||||
<:toggle>
|
||||
<span class="sr-only">{gettext("Choose which format to import")}</span>
|
||||
<span class="sr-only">{gettext("Choose which format to add")}</span>
|
||||
<.icon
|
||||
name="hero-plus"
|
||||
class="size-5 cursor-pointer text-zinc-500 dark:text-zinc-400"
|
||||
@@ -106,9 +288,19 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
<.focus_wrap id={"actions-#{@release_group.id}-focus-wrap"}>
|
||||
<.dropdown_link
|
||||
:for={format <- Records.Record.formats()}
|
||||
id={"actions-#{@release_group.id}-#{format}-import"}
|
||||
id={"actions-#{@release_group.id}-#{format}-add"}
|
||||
phx-click={
|
||||
JS.push("import", value: %{id: @release_group.id, format: format}, page_loading: true)
|
||||
JS.push("add_to_cart",
|
||||
value: %{
|
||||
id: @release_group.id,
|
||||
format: format,
|
||||
title: @release_group.title,
|
||||
artists: @release_group.artists,
|
||||
release_date: @release_group.release_date,
|
||||
thumb_url: ReleaseGroupSearchResult.thumb_url(@release_group)
|
||||
},
|
||||
target: @myself
|
||||
)
|
||||
}
|
||||
>
|
||||
{format_label(format)}
|
||||
@@ -124,14 +316,15 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
def mount(socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> stream_configure(:release_groups,
|
||||
dom_id: fn rg -> "musicbrainz_#{rg.id}" end
|
||||
)
|
||||
|> assign(:release_groups, [])
|
||||
|> assign(:release_groups_count, 0)
|
||||
|> assign(:release_groups_total_count, 0)
|
||||
|> stream(:release_groups, [])
|
||||
|> assign(:loaded_all_results?, false)
|
||||
|> assign(:error_message, nil)}
|
||||
|> assign(:error_message, nil)
|
||||
|> assign(:cart, [])
|
||||
|> assign(:cart_pairs, MapSet.new())
|
||||
|> assign(:cart_expanded?, true)
|
||||
|> assign(:importing?, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -148,7 +341,7 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
|> assign(:error_message, nil)
|
||||
|> assign(:release_groups_count, Enum.count(result.release_groups))
|
||||
|> assign(:release_groups_total_count, result.total_count)
|
||||
|> stream(:release_groups, result.release_groups, reset: true)
|
||||
|> assign(:release_groups, result.release_groups)
|
||||
|
||||
{:error, _reason} ->
|
||||
assign(
|
||||
@@ -163,19 +356,19 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
assign(socket,
|
||||
offset: 0,
|
||||
icon_name: assigns.icon_name,
|
||||
purchased_at_fn: assigns.purchased_at_fn,
|
||||
form: to_form(%{"mb_query" => mb_query})
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
def handle_event("search", %{"mb_query" => ""}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:offset, 0)
|
||||
|> assign(:release_groups_count, 0)
|
||||
|> assign(:release_groups_total_count, 0)
|
||||
|> stream(:release_groups, [], reset: true)
|
||||
|> assign(:release_groups, [])
|
||||
|> assign(:form, to_form(%{"mb_query" => ""}))}
|
||||
end
|
||||
|
||||
@@ -188,7 +381,7 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
|> assign(:offset, 0)
|
||||
|> assign(:release_groups_count, length(result.release_groups))
|
||||
|> assign(:release_groups_total_count, result.total_count)
|
||||
|> stream(:release_groups, result.release_groups, reset: true)
|
||||
|> assign(:release_groups, result.release_groups)
|
||||
|> assign(:form, to_form(%{"mb_query" => mb_query}))}
|
||||
|
||||
{:error, _reason} ->
|
||||
@@ -198,7 +391,7 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
|> assign(:offset, 0)
|
||||
|> assign(:release_groups_count, 0)
|
||||
|> assign(:release_groups_total_count, 0)
|
||||
|> stream(:release_groups, [], reset: true)
|
||||
|> assign(:release_groups, [])
|
||||
|> assign(:form, to_form(%{"mb_query" => mb_query}))}
|
||||
end
|
||||
end
|
||||
@@ -215,10 +408,209 @@ defmodule MusicLibraryWeb.Components.AddRecord do
|
||||
|> assign(:loaded_all_results?, length(result.release_groups) < @batch_size)
|
||||
|> assign(:release_groups_count, offset + length(result.release_groups))
|
||||
|> assign(:release_groups_total_count, result.total_count)
|
||||
|> stream(:release_groups, result.release_groups)}
|
||||
|> assign(
|
||||
:release_groups,
|
||||
socket.assigns.release_groups ++ result.release_groups
|
||||
)}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("add_to_cart", params, socket) do
|
||||
case parse_format(params["format"]) do
|
||||
{:ok, format} ->
|
||||
{:noreply, add_to_cart(socket, params, format)}
|
||||
|
||||
:error ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_from_cart", %{"cart_item_id" => id}, socket) do
|
||||
id = cast_id(id)
|
||||
{:noreply, remove_cart_item(socket, id)}
|
||||
end
|
||||
|
||||
def handle_event("change_format", %{"cart_item_id" => id, "format" => format_str}, socket) do
|
||||
id = cast_id(id)
|
||||
|
||||
case parse_format(format_str) do
|
||||
{:ok, format} -> {:noreply, change_cart_format(socket, id, format)}
|
||||
:error -> {:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("clear_cart", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cart, [])
|
||||
|> assign(:cart_pairs, MapSet.new())}
|
||||
end
|
||||
|
||||
def handle_event("toggle_cart", _params, socket) do
|
||||
{:noreply, assign(socket, :cart_expanded?, not socket.assigns.cart_expanded?)}
|
||||
end
|
||||
|
||||
def handle_event("import_cart", _params, socket) do
|
||||
case socket.assigns.cart do
|
||||
[] ->
|
||||
{:noreply, socket}
|
||||
|
||||
[single] ->
|
||||
purchased_at = socket.assigns.purchased_at_fn.()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:importing?, true)
|
||||
|> start_async(:import_cart, fn ->
|
||||
Records.import_from_musicbrainz_release_group(single.release_group_id,
|
||||
format: single.format,
|
||||
purchased_at: purchased_at
|
||||
)
|
||||
end)}
|
||||
|
||||
items ->
|
||||
purchased_at = socket.assigns.purchased_at_fn.()
|
||||
purchased_at_iso = purchased_at && DateTime.to_iso8601(purchased_at)
|
||||
|
||||
changesets =
|
||||
Enum.map(items, fn item ->
|
||||
ImportFromMusicbrainzReleaseGroup.new(%{
|
||||
"release_group_id" => item.release_group_id,
|
||||
"format" => Atom.to_string(item.format),
|
||||
"purchased_at" => purchased_at_iso
|
||||
})
|
||||
end)
|
||||
|
||||
Oban.insert_all(changesets)
|
||||
notify_parent({:imported_async, length(items)})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
# If the user closes the modal mid-import, this callback lands on a detached
|
||||
# component and is silently dropped by Phoenix — no user-visible bug.
|
||||
def handle_async(:import_cart, {:ok, {:ok, record}}, socket) do
|
||||
notify_parent({:imported_single, record})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:import_cart, {:ok, {:error, reason}}, socket) do
|
||||
put_toast!(
|
||||
:error,
|
||||
gettext("Error importing record") <> ": " <> ErrorMessages.friendly_message(reason)
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, :importing?, false)}
|
||||
end
|
||||
|
||||
def handle_async(:import_cart, {:exit, reason}, socket) do
|
||||
Logger.warning("Cart import crashed: #{inspect(reason)}")
|
||||
put_toast!(:error, gettext("Error importing record"))
|
||||
{:noreply, assign(socket, :importing?, false)}
|
||||
end
|
||||
|
||||
defp add_to_cart(socket, params, format) do
|
||||
rg_id = params["id"]
|
||||
pair = {rg_id, format}
|
||||
|
||||
if MapSet.member?(socket.assigns.cart_pairs, pair) do
|
||||
socket
|
||||
else
|
||||
item = %{
|
||||
cart_item_id: System.unique_integer([:positive]),
|
||||
release_group_id: rg_id,
|
||||
title: params["title"],
|
||||
artists: params["artists"],
|
||||
release_date: params["release_date"],
|
||||
thumb_url: params["thumb_url"],
|
||||
format: format
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:cart, [item | socket.assigns.cart])
|
||||
|> assign(:cart_pairs, MapSet.put(socket.assigns.cart_pairs, pair))
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_cart_item(socket, id) do
|
||||
{removed, kept} = Enum.split_with(socket.assigns.cart, &(&1.cart_item_id == id))
|
||||
|
||||
pairs =
|
||||
Enum.reduce(removed, socket.assigns.cart_pairs, fn item, acc ->
|
||||
MapSet.delete(acc, {item.release_group_id, item.format})
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:cart, kept)
|
||||
|> assign(:cart_pairs, pairs)
|
||||
end
|
||||
|
||||
defp change_cart_format(socket, id, new_format) do
|
||||
case Enum.find(socket.assigns.cart, &(&1.cart_item_id == id)) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%{format: ^new_format} ->
|
||||
socket
|
||||
|
||||
item ->
|
||||
new_pair = {item.release_group_id, new_format}
|
||||
|
||||
if MapSet.member?(socket.assigns.cart_pairs, new_pair) do
|
||||
socket
|
||||
else
|
||||
updated = %{item | format: new_format}
|
||||
|
||||
cart =
|
||||
Enum.map(socket.assigns.cart, fn
|
||||
^item -> updated
|
||||
other -> other
|
||||
end)
|
||||
|
||||
pairs =
|
||||
socket.assigns.cart_pairs
|
||||
|> MapSet.delete({item.release_group_id, item.format})
|
||||
|> MapSet.put(new_pair)
|
||||
|
||||
socket
|
||||
|> assign(:cart, cart)
|
||||
|> assign(:cart_pairs, pairs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_format(nil), do: {:ok, @default_format}
|
||||
|
||||
defp parse_format(format_str) when is_binary(format_str) do
|
||||
formats = Records.Record.formats()
|
||||
|
||||
case Enum.find(formats, fn f -> Atom.to_string(f) == format_str end) do
|
||||
nil -> :error
|
||||
format -> {:ok, format}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_format(format) when is_atom(format) do
|
||||
if format in Records.Record.formats(), do: {:ok, format}, else: :error
|
||||
end
|
||||
|
||||
defp cast_id(id) when is_integer(id), do: id
|
||||
defp cast_id(id) when is_binary(id), do: String.to_integer(id)
|
||||
|
||||
defp in_cart?(cart_pairs, rg_id) do
|
||||
Enum.any?(cart_pairs, fn {id, _format} -> id == rg_id end)
|
||||
end
|
||||
|
||||
defp cart_format(cart, rg_id) do
|
||||
case Enum.find(cart, &(&1.release_group_id == rg_id)) do
|
||||
nil -> nil
|
||||
item -> item.format
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
|
||||
@@ -154,6 +154,7 @@ defmodule MusicLibraryWeb.CoreComponents do
|
||||
attr :id, :string, required: true
|
||||
attr :on_close, :any, required: false, default: nil
|
||||
attr :open, :boolean, required: false, default: true
|
||||
attr :width_class, :string, required: false, default: "md:max-w-3xl"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
@@ -161,7 +162,7 @@ defmodule MusicLibraryWeb.CoreComponents do
|
||||
~H"""
|
||||
<Fluxon.Components.Modal.modal
|
||||
id={@id}
|
||||
class="mx-auto mt-8 max-w-sm sm:min-w-2xl md:max-w-3xl"
|
||||
class={"mx-auto mt-8 max-w-sm sm:min-w-2xl #{@width_class}"}
|
||||
animation="transition duration-100 ease-in-out"
|
||||
placement="top"
|
||||
open={@open}
|
||||
|
||||
@@ -8,6 +8,7 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
||||
|
||||
alias MusicLibrary.Chats
|
||||
alias MusicLibrary.Collection
|
||||
alias MusicLibraryWeb.Components.AddRecord
|
||||
alias MusicLibraryWeb.LiveHelpers.IndexActions
|
||||
|
||||
defp index_config do
|
||||
@@ -20,7 +21,6 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
||||
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"
|
||||
@@ -186,9 +186,10 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
||||
:if={@live_action == :import}
|
||||
id="record-modal"
|
||||
on_close={JS.patch(back_path(@record_list_params))}
|
||||
width_class="md:max-w-4xl lg:max-w-5xl"
|
||||
>
|
||||
<.live_component
|
||||
module={MusicLibraryWeb.Components.AddRecord}
|
||||
module={AddRecord}
|
||||
id={:search}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
@@ -196,6 +197,7 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
||||
patch={back_path(@record_list_params)}
|
||||
initial_query={@import_query}
|
||||
icon_name="hero-plus"
|
||||
purchased_at_fn={@index_config.purchased_at_fn}
|
||||
/>
|
||||
</.structured_modal>
|
||||
|
||||
@@ -284,6 +286,14 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
||||
IndexActions.handle_record_saved(socket)
|
||||
end
|
||||
|
||||
def handle_info({AddRecord, {:imported_single, record}}, socket) do
|
||||
IndexActions.handle_cart_imported_single(socket, record)
|
||||
end
|
||||
|
||||
def handle_info({AddRecord, {:imported_async, count}}, socket) do
|
||||
IndexActions.handle_cart_imported_async(socket, count)
|
||||
end
|
||||
|
||||
def handle_info({MusicLibraryWeb.Components.Chat, :chats_changed}, socket) do
|
||||
chat_count = Chats.count_chats(:collection, Chats.collection_musicbrainz_id())
|
||||
{:noreply, assign(socket, :chat_count, chat_count)}
|
||||
@@ -307,10 +317,6 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
||||
IndexActions.handle_search(socket, query)
|
||||
end
|
||||
|
||||
def handle_event("import", %{"id" => musicbrainz_id, "format" => format}, socket) do
|
||||
IndexActions.handle_import(socket, musicbrainz_id, format)
|
||||
end
|
||||
|
||||
def handle_event("set_display", %{"mode" => mode}, socket) do
|
||||
IndexActions.handle_set_display(socket, mode)
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
||||
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Wishlist
|
||||
alias MusicLibraryWeb.Components.AddRecord
|
||||
alias MusicLibraryWeb.LiveHelpers.IndexActions
|
||||
|
||||
defp index_config do
|
||||
@@ -18,7 +19,6 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
||||
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"
|
||||
@@ -154,9 +154,10 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
||||
:if={@live_action == :import}
|
||||
id="record-modal"
|
||||
on_close={JS.patch(back_path(@record_list_params))}
|
||||
width_class="md:max-w-4xl lg:max-w-5xl"
|
||||
>
|
||||
<.live_component
|
||||
module={MusicLibraryWeb.Components.AddRecord}
|
||||
module={AddRecord}
|
||||
id={:search}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
@@ -164,6 +165,7 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
||||
patch={back_path(@record_list_params)}
|
||||
initial_query={@import_query}
|
||||
icon_name="hero-plus"
|
||||
purchased_at_fn={@index_config.purchased_at_fn}
|
||||
/>
|
||||
</.structured_modal>
|
||||
|
||||
@@ -207,6 +209,14 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
||||
IndexActions.handle_record_saved(socket)
|
||||
end
|
||||
|
||||
def handle_info({AddRecord, {:imported_single, record}}, socket) do
|
||||
IndexActions.handle_cart_imported_single(socket, record)
|
||||
end
|
||||
|
||||
def handle_info({AddRecord, {:imported_async, count}}, socket) do
|
||||
IndexActions.handle_cart_imported_async(socket, count)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
IndexActions.handle_delete(socket, id)
|
||||
@@ -216,10 +226,6 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
||||
IndexActions.handle_search(socket, query)
|
||||
end
|
||||
|
||||
def handle_event("import", %{"id" => musicbrainz_id, "format" => format}, socket) do
|
||||
IndexActions.handle_import(socket, musicbrainz_id, format)
|
||||
end
|
||||
|
||||
def handle_event("add-to-collection", %{"id" => id}, socket) do
|
||||
record = Records.get_record!(id)
|
||||
current_time = DateTime.utc_now()
|
||||
|
||||
@@ -10,7 +10,6 @@ defmodule MusicLibraryWeb.LiveHelpers.IndexActions do
|
||||
use Gettext, backend: MusicLibraryWeb.Gettext
|
||||
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibraryWeb.ErrorMessages
|
||||
|
||||
@doc """
|
||||
Applies the :index action. Reads config from `socket.assigns.index_config`
|
||||
@@ -108,29 +107,31 @@ defmodule MusicLibraryWeb.LiveHelpers.IndexActions do
|
||||
{:noreply, push_patch(socket, to: config.index_path_fn.(qs))}
|
||||
end
|
||||
|
||||
def handle_import(socket, musicbrainz_id, format) do
|
||||
def handle_cart_imported_single(socket, record) 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))}
|
||||
end
|
||||
|
||||
def handle_cart_imported_async(socket, count) do
|
||||
config = socket.assigns.index_config
|
||||
|
||||
msg =
|
||||
ngettext(
|
||||
"Importing %{count} record in the background...",
|
||||
"Importing %{count} records in the background...",
|
||||
count,
|
||||
count: count
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_toast(
|
||||
:error,
|
||||
config.import_error_toast <> ": " <> ErrorMessages.friendly_message(reason)
|
||||
)
|
||||
|> put_toast(:info, msg)
|
||||
|> push_patch(to: config.base_index_path)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_set_display(socket, mode) do
|
||||
mode = parse_mode(mode)
|
||||
|
||||
@@ -64,12 +64,13 @@ msgstr ""
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/live/artist_live/show.ex
|
||||
#: lib/music_library_web/live/collection_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error importing record"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/components/record_form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Format"
|
||||
@@ -227,7 +228,6 @@ msgstr ""
|
||||
msgid "Scrobble activity"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/components/scrobble_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Choose which format to import"
|
||||
@@ -569,7 +569,6 @@ msgid "Add more · Artist"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/live/stats_live/index.ex
|
||||
#: lib/music_library_web/live/wishlist_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error wishlisting record"
|
||||
msgstr ""
|
||||
@@ -1588,6 +1587,7 @@ msgstr ""
|
||||
msgid "Record set updated successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/live/record_set_live/index.ex
|
||||
#: lib/music_library_web/live/record_set_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -2401,6 +2401,7 @@ msgid "Record has a selected release"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/barcode_scanner.ex
|
||||
#: lib/music_library_web/live_helpers/index_actions.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Importing %{count} record in the background..."
|
||||
msgid_plural "Importing %{count} records in the background..."
|
||||
@@ -2461,3 +2462,52 @@ msgstr ""
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A rule for this album already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} record"
|
||||
msgid_plural "%{count} records"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Add records from the search results to get started."
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cart"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Choose which format to add"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Clear all"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import %{count} record"
|
||||
msgid_plural "Import %{count} records"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "In cart · %{format}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle cart"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your cart is empty"
|
||||
msgstr ""
|
||||
|
||||
@@ -64,12 +64,13 @@ msgstr ""
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/live/artist_live/show.ex
|
||||
#: lib/music_library_web/live/collection_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error importing record"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/components/record_form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Format"
|
||||
@@ -227,7 +228,6 @@ msgstr ""
|
||||
msgid "Scrobble activity"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/components/scrobble_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Choose which format to import"
|
||||
@@ -569,7 +569,6 @@ msgid "Add more · Artist"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/live/stats_live/index.ex
|
||||
#: lib/music_library_web/live/wishlist_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error wishlisting record"
|
||||
msgstr ""
|
||||
@@ -1588,6 +1587,7 @@ msgstr ""
|
||||
msgid "Record set updated successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#: lib/music_library_web/live/record_set_live/index.ex
|
||||
#: lib/music_library_web/live/record_set_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -2401,6 +2401,7 @@ msgid "Record has a selected release"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/barcode_scanner.ex
|
||||
#: lib/music_library_web/live_helpers/index_actions.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Importing %{count} record in the background..."
|
||||
msgid_plural "Importing %{count} records in the background..."
|
||||
@@ -2461,3 +2462,52 @@ msgstr ""
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A rule for this album already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} record"
|
||||
msgid_plural "%{count} records"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Add records from the search results to get started."
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cart"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Choose which format to add"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Clear all"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import %{count} record"
|
||||
msgid_plural "Import %{count} records"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "In cart · %{format}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle cart"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/add_record.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your cart is empty"
|
||||
msgstr ""
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
defmodule MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroupTest do
|
||||
use MusicLibrary.DataCase
|
||||
|
||||
import MusicBrainz.Fixtures.ReleaseGroup
|
||||
import MusicLibrary.Fixtures.Records
|
||||
|
||||
alias MusicLibrary.Records.Record
|
||||
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
||||
|
||||
describe "perform/1" do
|
||||
test "imports a record from a MusicBrainz release group" do
|
||||
release_group_data = release_group(:marbles)
|
||||
release_group_id = release_group_id(:marbles)
|
||||
release_group_releases_data = release_group_releases(:marbles)
|
||||
|
||||
cover_data = marbles_cover_data()
|
||||
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
case conn.path_info do
|
||||
[_ws, _version, "release-group", ^release_group_id] ->
|
||||
Req.Test.json(conn, release_group_data)
|
||||
|
||||
[_ws, _version, "release"] ->
|
||||
Req.Test.json(conn, release_group_releases_data)
|
||||
|
||||
[_release_group, ^release_group_id, "front"] ->
|
||||
Plug.Conn.send_resp(conn, 200, cover_data)
|
||||
end
|
||||
end)
|
||||
|
||||
purchased_at = DateTime.utc_now()
|
||||
|
||||
assert :ok =
|
||||
perform_job(ImportFromMusicbrainzReleaseGroup, %{
|
||||
"release_group_id" => release_group_id,
|
||||
"format" => "cd",
|
||||
"purchased_at" => DateTime.to_iso8601(purchased_at)
|
||||
})
|
||||
|
||||
imported_record = Repo.get_by!(Record, musicbrainz_id: release_group_id)
|
||||
assert imported_record.title == "Marbles"
|
||||
assert imported_record.format == :cd
|
||||
assert imported_record.purchased_at == DateTime.truncate(purchased_at, :second)
|
||||
end
|
||||
|
||||
test "imports a wishlist record when purchased_at is nil" do
|
||||
release_group_data = release_group(:marbles)
|
||||
release_group_id = release_group_id(:marbles)
|
||||
release_group_releases_data = release_group_releases(:marbles)
|
||||
|
||||
cover_data = marbles_cover_data()
|
||||
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
case conn.path_info do
|
||||
[_ws, _version, "release-group", ^release_group_id] ->
|
||||
Req.Test.json(conn, release_group_data)
|
||||
|
||||
[_ws, _version, "release"] ->
|
||||
Req.Test.json(conn, release_group_releases_data)
|
||||
|
||||
[_release_group, ^release_group_id, "front"] ->
|
||||
Plug.Conn.send_resp(conn, 200, cover_data)
|
||||
end
|
||||
end)
|
||||
|
||||
assert :ok =
|
||||
perform_job(ImportFromMusicbrainzReleaseGroup, %{
|
||||
"release_group_id" => release_group_id,
|
||||
"format" => "vinyl",
|
||||
"purchased_at" => nil
|
||||
})
|
||||
|
||||
imported_record = Repo.get_by!(Record, musicbrainz_id: release_group_id)
|
||||
assert imported_record.purchased_at == nil
|
||||
assert imported_record.format == :vinyl
|
||||
end
|
||||
|
||||
test "returns error on transport failure" do
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, %Req.TransportError{reason: :timeout}} =
|
||||
perform_job(ImportFromMusicbrainzReleaseGroup, %{
|
||||
"release_group_id" => "nonexistent-release-group-id",
|
||||
"format" => "cd",
|
||||
"purchased_at" => DateTime.to_iso8601(DateTime.utc_now())
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,17 @@
|
||||
defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|
||||
use MusicLibraryWeb.ConnCase
|
||||
use Oban.Testing, repo: MusicLibrary.BackgroundRepo
|
||||
|
||||
import MusicBrainz.Fixtures.Release
|
||||
import MusicBrainz.Fixtures.ReleaseGroup
|
||||
import MusicLibrary.Fixtures.Records
|
||||
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
|
||||
import Phoenix.LiveViewTest, only: [assert_redirect: 2]
|
||||
|
||||
alias MusicBrainz.ReleaseGroupSearchResult
|
||||
alias MusicLibrary.Assets
|
||||
alias MusicLibrary.Assets.{Image, Transform}
|
||||
alias MusicLibrary.Records.Record
|
||||
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
||||
|
||||
# make it a multiple of 4 for easier calculations
|
||||
@default_records_page_size 4
|
||||
@@ -281,20 +283,159 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|
||||
|> assert_has("input[value='test query']")
|
||||
end
|
||||
|
||||
test "imports a record when selected", %{conn: conn} do
|
||||
release_group_search_results = Map.get(release_group_search_results(), "release-groups")
|
||||
test "adds a record to the cart instead of importing immediately", %{conn: conn} do
|
||||
stub_release_group_search()
|
||||
|
||||
first_release_group_search_result = hd(release_group_search_results)
|
||||
first_release_group_search_result_id = first_release_group_search_result["id"]
|
||||
[first | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|> assert_has("#musicbrainz_#{first_id} span", text: "In cart · CD")
|
||||
|> assert_has("#cart-items li", count: 1)
|
||||
|
||||
assert MusicLibrary.Repo.all(Record) == []
|
||||
refute_enqueued(worker: ImportFromMusicbrainzReleaseGroup)
|
||||
end
|
||||
|
||||
test "deduplicates the same {release_group, format} pair", %{conn: conn} do
|
||||
stub_release_group_search()
|
||||
|
||||
[first | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
|
||||
session =
|
||||
conn
|
||||
|> visit(~p"/collection/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|
||||
assert_has(session, "#cart-items li", count: 1)
|
||||
end
|
||||
|
||||
test "allows same release group with different formats", %{conn: conn} do
|
||||
stub_release_group_search()
|
||||
|
||||
[first | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
|
||||
session =
|
||||
conn
|
||||
|> visit(~p"/collection/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "Vinyl")
|
||||
|
||||
assert_has(session, "#cart-items li", count: 2)
|
||||
end
|
||||
|
||||
test "removes an item from the cart", %{conn: conn} do
|
||||
stub_release_group_search()
|
||||
|
||||
[first | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
|
||||
session =
|
||||
conn
|
||||
|> visit(~p"/collection/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|> click_button("#cart-items button", "Remove")
|
||||
|
||||
assert_has(session, "#cart-empty")
|
||||
end
|
||||
|
||||
test "imports a single cart item synchronously and navigates", %{conn: conn} do
|
||||
alias Phoenix.LiveViewTest, as: LVT
|
||||
|
||||
stub_full_import()
|
||||
|
||||
[first | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
|
||||
{:ok, view, _html} = LVT.live(conn, ~p"/collection/import")
|
||||
|
||||
view
|
||||
|> LVT.form("#import_form", %{"mb_query" => "Marillion Marbles"})
|
||||
|> LVT.render_change()
|
||||
|
||||
view
|
||||
|> LVT.element("#musicbrainz_#{first_id} a", "CD")
|
||||
|> LVT.render_click()
|
||||
|
||||
view
|
||||
|> LVT.element("button", "Import 1 record")
|
||||
|> LVT.render_click()
|
||||
|
||||
{path, _flash} = assert_redirect(view, 2_000)
|
||||
"/collection/" <> record_id = path
|
||||
|
||||
record = MusicLibrary.Records.get_record!(record_id)
|
||||
|
||||
assert record.musicbrainz_id == first_id
|
||||
assert record.title == "Marbles"
|
||||
assert record.format == :cd
|
||||
assert record.purchased_at != nil
|
||||
|
||||
{:ok, resized_cover_data} = Image.resize(marbles_cover_data())
|
||||
assets = Assets.get(record.cover_hash)
|
||||
assert assets.content == resized_cover_data
|
||||
|
||||
refute_enqueued(worker: ImportFromMusicbrainzReleaseGroup)
|
||||
end
|
||||
|
||||
test "enqueues one job per cart item for 2+ items and closes modal", %{conn: conn} do
|
||||
stub_release_group_search()
|
||||
|
||||
[first, second | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
second_id = second["id"]
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|> click_link("#musicbrainz_#{second_id} a", "Vinyl")
|
||||
|> click_button("Import 2 records")
|
||||
|> assert_has("p", text: "Importing 2 records in the background...")
|
||||
|
||||
assert_enqueued(
|
||||
worker: ImportFromMusicbrainzReleaseGroup,
|
||||
args: %{"release_group_id" => first_id, "format" => "cd"}
|
||||
)
|
||||
|
||||
assert_enqueued(
|
||||
worker: ImportFromMusicbrainzReleaseGroup,
|
||||
args: %{"release_group_id" => second_id, "format" => "vinyl"}
|
||||
)
|
||||
|
||||
assert MusicLibrary.Repo.all(Record) == []
|
||||
end
|
||||
end
|
||||
|
||||
defp stub_release_group_search do
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
case conn.path_info do
|
||||
[_ws, _version, "release-group"] ->
|
||||
Req.Test.json(conn, release_group_search_results())
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp stub_full_import do
|
||||
[first | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
|
||||
release_group = release_group(:marbles)
|
||||
release_group_releases = release_group_releases(:marbles)
|
||||
|
||||
cover_data = marbles_cover_data()
|
||||
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
case conn.path_info do
|
||||
[_ws, _version, "release-group", ^first_release_group_search_result_id] ->
|
||||
[_ws, _version, "release-group", ^first_id] ->
|
||||
Req.Test.json(conn, release_group)
|
||||
|
||||
[_ws, _version, "release-group"] ->
|
||||
@@ -303,71 +444,10 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|
||||
[_ws, _version, "release"] ->
|
||||
Req.Test.json(conn, release_group_releases)
|
||||
|
||||
[_release_group, ^first_release_group_search_result_id, "front"] ->
|
||||
[_release_group, ^first_id, "front"] ->
|
||||
Plug.Conn.send_resp(conn, 200, cover_data)
|
||||
end
|
||||
end)
|
||||
|
||||
session =
|
||||
conn
|
||||
|> visit(~p"/collection/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|
||||
for release_group_search_result <- release_group_search_results do
|
||||
result = ReleaseGroupSearchResult.from_api_response(release_group_search_result)
|
||||
|
||||
session
|
||||
|> assert_has("h1", result.artists)
|
||||
|> assert_has("h2", result.title)
|
||||
|> assert_has("p", Record.format_release_date(result.release_date))
|
||||
end
|
||||
|
||||
session =
|
||||
session
|
||||
|> click_link("#musicbrainz_#{first_release_group_search_result_id} a", "CD")
|
||||
|
||||
[record] = MusicLibrary.Repo.all(MusicLibrary.Records.Record)
|
||||
|
||||
assert record.musicbrainz_id == first_release_group_search_result_id
|
||||
assert record.title == "Marbles"
|
||||
assert record.release_date == "2004-05-03"
|
||||
assert record.format == :cd
|
||||
assert record.musicbrainz_data == release_group
|
||||
|
||||
assert record.genres == [
|
||||
"alternative rock",
|
||||
"art rock",
|
||||
"baroque pop",
|
||||
"pop rock",
|
||||
"progressive rock",
|
||||
"psychedelic pop",
|
||||
"rock"
|
||||
]
|
||||
|
||||
assert record.cover_hash ==
|
||||
"E7238C742E5B8711FC5BFF01A4A1F727D9E404A4D1420429A6B37ABFFC0B5960"
|
||||
|
||||
{:ok, resized_cover_data} = Image.resize(cover_data)
|
||||
|
||||
assets = Assets.get(record.cover_hash)
|
||||
|
||||
assert assets.content == resized_cover_data
|
||||
|
||||
assert record.inserted_at !== nil
|
||||
assert record.updated_at !== nil
|
||||
assert record.purchased_at !== nil
|
||||
|
||||
[marillion] = record.artists
|
||||
|
||||
assert %MusicLibrary.Artists.Artist{
|
||||
name: "Marillion",
|
||||
sort_name: "Marillion",
|
||||
disambiguation: "British progressive rock band",
|
||||
musicbrainz_id: "1932f5b6-0b7b-4050-b1df-833ca89e5f44"
|
||||
} = marillion
|
||||
|
||||
assert_path(session, ~p"/collection/#{record.id}")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Add via barcode scan" do
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
defmodule MusicLibraryWeb.WishlistLive.IndexTest do
|
||||
use MusicLibraryWeb.ConnCase
|
||||
use Oban.Testing, repo: MusicLibrary.BackgroundRepo
|
||||
|
||||
import MusicBrainz.Fixtures.ReleaseGroup
|
||||
import MusicLibrary.Fixtures.Records
|
||||
|
||||
alias MusicLibrary.Records.Record
|
||||
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
||||
|
||||
defp fill_wishlist(_) do
|
||||
records = Enum.map(1..5, fn _ -> record(%{purchased_at: nil}) end)
|
||||
%{wishlist: records}
|
||||
@@ -27,4 +32,49 @@ defmodule MusicLibraryWeb.WishlistLive.IndexTest do
|
||||
assert purchased_record.purchased_at !== nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "Adding a new record" do
|
||||
test "enqueues one job per cart item with purchased_at: nil for wishlist", %{conn: conn} do
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
case conn.path_info do
|
||||
[_ws, _version, "release-group"] ->
|
||||
Req.Test.json(conn, release_group_search_results())
|
||||
end
|
||||
end)
|
||||
|
||||
[first, second | _] = Map.get(release_group_search_results(), "release-groups")
|
||||
first_id = first["id"]
|
||||
second_id = second["id"]
|
||||
|
||||
conn
|
||||
|> visit(~p"/wishlist/import")
|
||||
|> fill_in("Search for a record", with: "Marillion Marbles")
|
||||
|> click_link("#musicbrainz_#{first_id} a", "CD")
|
||||
|> click_link("#musicbrainz_#{second_id} a", "Vinyl")
|
||||
|> click_button("Import 2 records")
|
||||
|> assert_has("p", text: "Importing 2 records in the background...")
|
||||
|
||||
assert_enqueued(
|
||||
worker: ImportFromMusicbrainzReleaseGroup,
|
||||
args: %{
|
||||
"release_group_id" => first_id,
|
||||
"format" => "cd",
|
||||
"purchased_at" => nil
|
||||
}
|
||||
)
|
||||
|
||||
assert_enqueued(
|
||||
worker: ImportFromMusicbrainzReleaseGroup,
|
||||
args: %{
|
||||
"release_group_id" => second_id,
|
||||
"format" => "vinyl",
|
||||
"purchased_at" => nil
|
||||
}
|
||||
)
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
query = from(r in Record, where: not is_nil(r.purchased_at))
|
||||
assert MusicLibrary.Repo.all(query) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user