Fix changeset handling by recycling

This commit is contained in:
Chris McCord 2021-11-08 13:46:23 -05:00
parent 5f593dfaf2
commit 6358b0bb3b
7 changed files with 130 additions and 91 deletions

View file

@ -11,9 +11,7 @@
.fade-out-scale { .fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
} }
.slide-in-right {
animation: slide-in-right-keys 0.2s forwards;
}
.fade-in { .fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
} }
@ -75,9 +73,7 @@
display: none; display: none;
} }
.invalid-feedback { .invalid-feedback {
color: #a94442; display: inline-block;
display: block;
margin: -1rem 0 2rem;
} }
/* LiveView specific classes for your customization */ /* LiveView specific classes for your customization */

View file

@ -27,9 +27,11 @@ Hooks.AudioPlayer = {
this.duration = this.el.querySelector("#player-duration") this.duration = this.el.querySelector("#player-duration")
this.progress = this.el.querySelector("#player-progress") this.progress = this.el.querySelector("#player-progress")
let enableAudio = () => { let enableAudio = () => {
document.removeEventListener("click", enableAudio) if(this.player.src){
this.player.play().catch(error => null) document.removeEventListener("click", enableAudio)
this.player.pause() this.player.play().catch(error => null)
this.player.pause()
}
} }
document.addEventListener("click", enableAudio) document.addEventListener("click", enableAudio)
this.el.addEventListener("js:listen_now", () => this.play({sync: true})) this.el.addEventListener("js:listen_now", () => this.play({sync: true}))

View file

@ -187,7 +187,15 @@ defmodule LiveBeats.MediaLibrary do
Repo.delete(song) Repo.delete(song)
end end
def change_song(%Song{} = song, attrs \\ %{}) do def change_song(song_or_changeset, attrs \\ %{}) do
Song.changeset(song, attrs) song_or_changeset
|> recycle_changeset()
|> Song.changeset(attrs)
end end
defp recycle_changeset(%Ecto.Changeset{} = changeset) do
Map.merge(changeset, %{action: nil, errors: [], valid?: true})
end
defp recycle_changeset(%{} = other), do: other
end end

View file

@ -1,8 +1,12 @@
defmodule LiveBeatsWeb.SongLive.SongEntry do defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
use LiveBeatsWeb, :live_component use LiveBeatsWeb, :live_component
alias LiveBeats.MP3Stat alias LiveBeats.MP3Stat
def send_progress(%Phoenix.LiveView.UploadEntry{} = entry) do
send_update(__MODULE__, id: entry.ref, progress: entry.progress)
end
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start sm:border-t sm:border-gray-200 sm:pt-2"> <div class="sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start sm:border-t sm:border-gray-200 sm:pt-2">
@ -19,13 +23,15 @@ defmodule LiveBeatsWeb.SongLive.SongEntry do
</label> </label>
<input type="text" name={"songs[#{@ref}][title]"} value={@title} <input type="text" name={"songs[#{@ref}][title]"} value={@title}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/> class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
<%= error_tag(@errors, :title, "songs[#{@ref}][title]") %>
</div> </div>
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600"> <div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
<label for="name" class="block text-xs font-medium text-gray-900">Artist</label> <label for="name" class="block text-xs font-medium text-gray-900">Artist</label>
<input type="text" name={"songs[#{@ref}][artist]"} value={@artist} <input type="text" name={"songs[#{@ref}][artist]"} value={@artist}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/> class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
<%= error_tag(@errors, :artist, "songs[#{@ref}][artist]") %> </div>
<div class="col-span-full sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start">
<.error input_name={"songs[#{@ref}][title]"} field={:title} errors={@errors}/>
<.error input_name={"songs[#{@ref}][artist]"} field={:artist} errors={@errors}/>
</div> </div>
<div style={"width: #{@progress}%;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0"> <div style={"width: #{@progress}%;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0">
</div> </div>

View file

@ -2,7 +2,7 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
use LiveBeatsWeb, :live_component use LiveBeatsWeb, :live_component
alias LiveBeats.{MediaLibrary, MP3Stat} alias LiveBeats.{MediaLibrary, MP3Stat}
alias LiveBeatsWeb.SongLive.SongEntry alias LiveBeatsWeb.SongLive.SongEntryComponent
@max_songs 10 @max_songs 10
@ -12,8 +12,8 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
{:ok, %MP3Stat{} = stat} -> {:ok, %MP3Stat{} = stat} ->
{:ok, put_stats(socket, entry_ref, stat)} {:ok, put_stats(socket, entry_ref, stat)}
_ -> {:error, _} ->
{:ok, cancel_upload(socket, :mp3, entry_ref)} {:ok, cancel_changeset_upload(socket, entry_ref, :not_accepted)}
end end
end end
@ -54,7 +54,7 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
|> push_redirect(to: Routes.song_index_path(socket, :index))} |> push_redirect(to: Routes.song_index_path(socket, :index))}
{:error, _reason} -> {:error, _reason} ->
{:noreply, put_flash(socket, :error, "There were problems uploading your songs")} {:noreply, socket}
end end
end end
@ -69,7 +69,6 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
new_changeset = new_changeset =
acc acc
|> get_changeset(ref) |> get_changeset(ref)
|> Ecto.Changeset.apply_changes()
|> MediaLibrary.change_song(song_params) |> MediaLibrary.change_song(song_params)
|> Map.put(:action, action) |> Map.put(:action, action)
@ -103,8 +102,12 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
update(socket, :changesets, &Map.put(&1, entry_ref, changeset)) update(socket, :changesets, &Map.put(&1, entry_ref, changeset))
end end
defp drop_changeset(socket, entry_ref) do
update(socket, :changesets, &Map.delete(&1, entry_ref))
end
defp handle_progress(:mp3, entry, socket) do defp handle_progress(:mp3, entry, socket) do
send_update(SongEntry, id: entry.ref, progress: entry.progress) SongEntryComponent.send_progress(entry)
if entry.done? do if entry.done? do
async_calculate_duration(socket, entry) async_calculate_duration(socket, entry)
@ -144,21 +147,32 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
defp drop_invalid_uploads(socket) do defp drop_invalid_uploads(socket) do
%{uploads: uploads} = socket.assigns %{uploads: uploads} = socket.assigns
{new_socket, error_messages, _index} = Enum.reduce(Enum.with_index(uploads.mp3.entries), socket, fn {entry, i}, socket ->
Enum.reduce(uploads.mp3.entries, {socket, [], 0}, fn entry, {socket, msgs, i} -> if i >= @max_songs do
if i >= @max_songs do cancel_changeset_upload(socket, entry.ref, :dropped)
{cancel_upload(socket, :mp3, entry.ref), [{entry.client_name, :dropped} | msgs], i + 1} else
else case upload_errors(uploads.mp3, entry) do
case upload_errors(uploads.mp3, entry) do [first | _] ->
[first | _] -> cancel_changeset_upload(socket, entry.ref, first)
{cancel_upload(socket, :mp3, entry.ref), [{entry.client_name, first} | msgs], i + 1}
[] -> [] ->
{socket, msgs, i + 1} socket
end
end end
end) end
end)
end
assign(new_socket, error_messages: error_messages) defp cancel_changeset_upload(socket, entry_ref, reason) do
entry = get_entry!(socket, entry_ref)
socket
|> cancel_upload(:mp3, entry.ref)
|> drop_changeset(entry.ref)
|> update(:error_messages, &(&1 ++ [{entry.client_name, reason}]))
end
defp get_entry!(socket, entry_ref) do
Enum.find(socket.assigns.uploads.mp3.entries, fn entry -> entry.ref == entry_ref end) ||
raise "no entry found for ref #{inspect(entry_ref)}"
end end
end end

View file

@ -10,57 +10,57 @@
phx-submit="save"> phx-submit="save">
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5"> <div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
<div class="space-y-2 sm:space-y-2"> <div class="space-y-2 sm:space-y-2">
<%= for {ref, changeset} <- @changesets do %> <%= for {ref, changeset} <- @changesets do %>
<.live_component id={ref} module={SongEntry} changeset={changeset} /> <.live_component id={ref} module={SongEntryComponent} changeset={changeset} />
<% end %> <% end %>
<!-- upload --> <!-- upload -->
<div class="sm:grid sm:border-t sm:border-gray-200 sm:pt-5"> <div class="sm:grid sm:border-t sm:border-gray-200 sm:pt-5">
<div class="mt-1 sm:mt-0" phx-drop-target={@uploads.mp3.ref}> <div class="mt-1 sm:mt-0" phx-drop-target={@uploads.mp3.ref}>
<%= if Enum.any?(@error_messages) do %> <%= if Enum.any?(@error_messages) do %>
<div class="rounded-md bg-red-50 p-4 mb-2"> <div class="rounded-md bg-red-50 p-4 mb-2">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<.icon name={:x_circle} class="h-5 w-5 text-red-400"/> <.icon name={:x_circle} class="h-5 w-5 text-red-400"/>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-medium text-red-800"> <h3 class="text-sm font-medium text-red-800">
Oops! Oops!
</h3> </h3>
<div class="mt-2 text-sm text-red-700"> <div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc pl-5 space-y-1"> <ul role="list" class="list-disc pl-5 space-y-1">
<%= for {client_name, error} <- @error_messages do %> <%= for {client_name, kind} <- @error_messages do %>
<li><%= client_name %>: <.file_error kind={error} /></li> <li><%= client_name %>: <.file_error kind={kind} /></li>
<% end %> <% end %>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% end %> <% end %>
<div class="max-w-lg flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"> <div class="max-w-lg flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true"> <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg> </svg>
<div class="flex text-sm text-gray-600"> <div class="flex text-sm text-gray-600">
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"> <label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span phx-click={JS.dispatch("click", to: "##{@uploads.mp3.ref}")}>Upload files</span> <span phx-click={JS.dispatch("click", to: "##{@uploads.mp3.ref}")}>Upload files</span>
<%= live_file_input @uploads.mp3, class: "sr-only" %> <%= live_file_input @uploads.mp3, class: "sr-only" %>
</label> </label>
<p class="pl-1">or drag and drop</p> <p class="pl-1">or drag and drop</p>
</div> </div>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
MP3s up to 20MB MP3s up to 20MB
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- /upload --> <!-- /upload -->
</div> </div>
</div> </div>
</.form> </.form>
</div> </div>

View file

@ -4,25 +4,38 @@ defmodule LiveBeatsWeb.ErrorHelpers do
""" """
use Phoenix.HTML use Phoenix.HTML
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
@doc """ @doc """
Generates tag for inlined form input errors. Generates tag for inlined form input errors.
""" """
def error_tag(form, field) do def error_tag(form, field) do
error_tag(form.errors, field, input_name(form, field)) error(%{errors: form.errors, field: field, input_name: input_name(form, field)})
end end
def error_tag(errors, field, input_name) do def error(%{errors: errors, field: field} = assigns) do
Enum.map(Keyword.get_values(errors, field), fn error -> assigns =
content_tag(:div, translate_error(error), assigns
class: "invalid-feedback mt-0 text-sm text-red-600 text-right", |> assign(:error_values, Keyword.get_values(errors, field))
phx_feedback_for: input_name |> assign_new(:class, fn -> "" end)
)
end) ~H"""
<%= for error <- @error_values do %>
<div
phx-feedback-for={@input_name}
class={"invalid-feedback -mt-1 pl-2 text-sm text-white bg-red-600 rounded-md #{@class}"}
>
<%= translate_error(error) %>
</div>
<% end %>
<%= if Enum.empty?(@error_values) do %>
<div class={"invalid-feedback h-0 #{@class}"}></div>
<% end %>
"""
end end
@doc """ @doc """
Translates an error message using gettext. Translates an error message using gettext.
""" """