I spent ~24 hours working on the following file uploader for a side project I am working on. I am facing an issue with auto-closing the component when the file upload. Here’s how the component is structures:
- button (always visible) acting as a trigger to open the drawer below
- drawer shows a panel that contains a form to upload a form
I build this component to be as self contained as it can get because I want to show this component on several pages of my application.
Note I may be wrong in my approach as I am by no means an Elixir or Phoenix expert (I am learning) not a trained software engineer (I am on the UX side actually).
Could someone point me what am I doing wrong?
defmodule ProjectWeb.Orders.OrderUploader do
@moduledoc false
use Phoenix.LiveComponent
use Gettext, backend: ProjectWeb.Gettext
import ProjectWeb.Core.Button
import ProjectWeb.Core.Icon
import ProjectWeb.Core.Input
alias Phoenix.LiveView.JS
alias Project.Brokers.BrokerRegistry
@allowed_file_formats ~w(.csv .xls .xlsx)
@error_not_accepted gettext("Please select a CSV, XLS, or XLSX file")
@error_too_large gettext("The file you selected is too large (maximum size is 10MB)")
@error_too_many_files gettext("You can only upload one file at a time")
@error_no_broker gettext("Please select a broker from the list below")
@max_file_size 10 * 1_024 * 1_024
@impl true
def mount(socket) do
socket =
socket
|> allow_upload(:orders,
accept: @allowed_file_formats,
auto_upload: true,
max_entries: 1,
max_file_size: @max_file_size,
progress: &handle_progress/3
)
|> assign(:broker, nil)
|> assign(:close_on_click_away, true)
|> assign(:close_on_escape, true)
|> assign(:errors, [])
|> assign(:is_uploader_visible?, false)
|> assign(:size, "md")
|> assign(:timezone, nil)
|> assign(:timezone_fallback, "America/New_York")
|> assign(:upload_progress, 0)
|> assign_brokers()
{:ok, socket}
end
def handle_progress(:orders, entry, socket) do
if entry.done? do
{:noreply, assign(socket, :upload_progress, 100)}
else
{:noreply, assign(socket, :upload_progress, entry.progress)}
end
end
# Events
@impl true
def handle_event("hide-uploader", _params, socket) do
{:noreply, assign(socket, is_uploader_visible?: false)}
end
@impl true
def handle_event("remove-file", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :orders, ref)}
end
@impl true
def handle_event("show-uploader", _params, socket) do
{:noreply, assign(socket, is_uploader_visible?: true)}
end
@impl true
def handle_event("upload", %{"broker" => broker, "timezone" => timezone}, socket) do
if is_nil(broker) || broker == "" do
errors = [@error_no_broker | socket.assigns.errors]
{:noreply, assign(socket, errors: errors)}
else
# Check if we have any uploads
if Enum.empty?(socket.assigns.uploads.orders.entries) do
errors = [gettext("Please select a file to upload") | socket.assigns.errors]
{:noreply, assign(socket, errors: errors)}
else
# Process uploads - collect info needed for async processing
_upload_info =
consume_uploaded_entries(socket, :orders, fn %{path: path}, entry ->
# Return the info needed for processing
{:ok,
%{
path: path,
broker: broker,
timezone: timezone,
filename: entry.client_name
}}
end)
# In a real implementation:
# 1. Send the upload_info to a background job processor
# 2. Store job ID for tracking progress
# 3. Set up a PubSub subscription for progress updates
# Close the uploader and show a notification
# send(self(), {:orders_upload_complete, gettext("Your file has been uploaded and is being processed.")})
socket = assign(socket, is_uploader_visible?: false)
{:noreply, socket}
end
end
end
@impl true
def handle_event("validate", params, socket) do
broker = params["broker"]
errors = socket.assigns.errors
upload = socket.assigns.uploads.orders
# Step 1: Update broker-related errors
errors = maybe_remove_broker_error(errors, broker)
# Step 2: Collect upload-related errors
upload_errors = validate_upload_errors(upload)
# Step 3: Merge and deduplicate errors
combined_errors =
if upload_errors == [] do
remove_upload_errors(errors)
else
Enum.uniq(errors ++ upload_errors)
end
socket =
socket
|> assign(broker: broker)
|> assign(errors: combined_errors)
{:noreply, socket}
end
# Render
@impl true
def render(assigns) do
~H"""
<div id={@id <> "-wrapper"}>
<%!-- Trigger --%>
<.button phx-click={JS.push("show-uploader", target: @myself) |> show(@id)}>
<.icon name="hero-arrow-up-tray-mini" class="mr-2 size-4" />
{gettext("Import Trades")}
</.button>
<%!-- Drawer --%>
<div
id={@id}
class="hidden relative z-10"
phx-mounted={@is_uploader_visible? && show(@id)}
phx-remove={!@is_uploader_visible? && hide(@id)}
phx-target={@myself}
role="dialog"
aria-labelledby={@id <> "-title"}
aria-modal="true"
>
<%!-- Backdrop --%>
<div
id={@id <> "-backdrop"}
class="hidden fixed inset-0 bg-gray-500/75 transition-opacity backdrop-blur"
aria-hidden="true"
>
</div>
<%!-- Panel --%>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div
id={@id <> "-panel"}
class={"mt-16 pointer-events-auto relative w-screen max-w-#{@size} transform translate-x-full"}
phx-click-away={
@close_on_click_away && JS.push("hide-uploader", target: @myself) |> hide(@id)
}
phx-window-keydown={
@close_on_escape && JS.push("hide-uploader", target: @myself) |> hide(@id)
}
phx-key="escape"
>
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-gray-900 shadow-xl">
<%!-- Panel Header --%>
<div class="bg-green-600 px-4 py-6 sm:px-6">
<div class="flex items-center justify-between">
<h2 id={@id <> "-title"} class="text-base font-semibold text-white">
{gettext("Import Trades")}
</h2>
<div class="ml-3 flex h-7 item-center">
<button
type="button"
class="relative rounded bg-green-600 text-green-200 hover:text-white focus:ring-2 focus:ring-white focus:outline-none p-0 flex items-center justify-center h-7 w-7"
phx-click={JS.push("hide-uploader", target: @myself) |> hide(@id)}
>
<span class="absolute -inset-2.5"></span>
<span class="sr-only">{gettext("Close panel")}</span>
<.icon name="hero-x-mark" class="size-6" />
</button>
</div>
</div>
<div class="mt-2">
<p class="text-sm text-green-200">
{gettext("Trade uploader hint text")}
</p>
</div>
</div>
<%!-- Errors --%>
<div
:if={!Enum.empty?(@errors)}
class="mt-6 relative rounded-lg mx-6 p-4 bg-red-100"
>
<div class="flex flex-start">
<div class="shrink-0">
<.icon name="hero-exclamation-circle-solid" class="size-6 text-red-400" />
</div>
<div class="flex-1 ml-3 min-w-0 mt-0 5">
<p class="text-sm leading-6 text-red-800 font-medium">{gettext("Error")}</p>
<ul class="mt-1 text-sm text-red-700">
<li :for={error <- @errors}>{error}</li>
</ul>
</div>
</div>
</div>
<%!-- Form --%>
<form
id={@id <> "-form"}
class="relative flex flex-1 flex-col justify-between divide-y divide-gray-900/10 dark:divide-white/10"
phx-change="validate"
phx-submit="upload"
phx-target={@myself}
multipart
>
<div class="flex-1 overflow-y-auto px-4 sm:px-6 divide-y divide-gray-900/10 dark:divide-white/10">
<%!-- Inputs --%>
<div class="py-6">
<div class="space-y-3">
<.input
type="select"
label={gettext("Broker")}
name="broker"
prompt={gettext("Select a broker...")}
options={@brokers}
value={@broker}
/>
<.input
type="select"
label={gettext("Timezone")}
name="timezone"
prompt={gettext("Select the data timezone...")}
options={Timex.timezones()}
value={@timezone || @timezone_fallback}
/>
</div>
</div>
<%!-- Dropzone --%>
<div class="py-6 space-y-6">
<div
id={@id <> "-dropzone"}
class="rounded-lg rounded-lg border-gray-900/25 dark:border-white/25 px-6 py-10 flex justify-center border border-dashed"
phx-drop-target={@uploads.orders.ref}
>
<div class="text-center">
<.icon
name="hero-document-arrow-up-solid"
class="mx-auto size-12 text-gray-300 dark:text-white/30"
/>
<div class="mt-4 flex text-sm/6 text-gray-600 dark:text-gray-100">
<label
for={@uploads.orders.ref}
class="relative cursor-pointer rounded-md bg-white dark:bg-gray-900 font-semibold text-green-600 dark:text-green-500 focus-within:ring-2 focus-within:ring-green-600 focus-within:ring-offset-2 focus-within:outline-hidden hover:text-green-500"
>
<span>{gettext("Select")}</span>
<.live_file_input upload={@uploads.orders} class="sr-only" />
</label>
<p class="pl-1">{gettext("or drag and drop a file here")}</p>
</div>
<p class="text-xs/5 text-gray-600 dark:text-gray-100">
{gettext("(CSV, XLS, XlSX - Max. file size: 10MB)")}
</p>
</div>
</div>
<div :if={!Enum.empty?(@uploads.orders.entries)} class="space-y-3">
<%= for entry <- @uploads.orders.entries do %>
<div
:if={entry.valid?}
class="relative rounded-lg p-4 bg-gray-100 dark:bg-gray-800"
>
<!-- Main content container -->
<div class="flex">
<div class="shrink-0">
<%!-- TODO: design an icon with a document and table cell --%>
<.icon
name="hero-document-chart-bar"
class="size-12 text-green-600"
/>
</div>
<div class="ml-2 min-w-0 flex-1">
<div class="flex justify-between">
<p class="text-sm/7 font-medium text-gray-900 dark:text-white truncate">
{entry.client_name}
</p>
<div class="ml-4 relative">
<button
type="button"
class="-m-1 p-1 rounded-lg text-gray-400 transition dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/10 hover:text-gray-500 block bg-transparent"
phx-click="remove-file"
phx-value-ref={entry.ref}
phx-target={@myself}
aria-label={gettext("Remove file")}
>
<.icon name="hero-x-mark" class="size-5 " />
</button>
</div>
</div>
<div class="flex justify-between text-sm truncate text-gray-500 dark:text-gray-100">
<p>
{format_bytes(entry.client_size)}<span :if={
entry.progress == 100
}> • {gettext("Upload complete")}</span>
</p>
<span :if={entry.progress < 100}>{"#{entry.progress}%"}</span>
</div>
</div>
</div>
<div
:if={entry.progress < 100}
class="w-full bg-gray-200 rounded-full mt-4"
>
<div
class="h-1 ease-out bg-green-600 rounded-full transition-all duration-300"
style={"width: #{entry.progress}%"}
>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Footer Buttons --%>
<div class="flex shrink-0 justify-end p-4 space-x-4">
<.button
variant={:secondary}
phx-click={JS.push("hide-uploader", target: @myself) |> hide(@id)}
>
{gettext("Cancel")}
</.button>
<.button>{gettext("Import Trades")}</.button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
# Private Assigns
defp assign_brokers(socket) do
brokers =
BrokerRegistry.all()
|> Enum.filter(fn {_id, broker} -> broker.support != nil end)
|> Enum.map(fn {id, broker} -> {broker.name, id} end)
assign(socket, brokers: brokers)
end
# Private Helpers
defp format_bytes(bytes) when is_integer(bytes) do
cond do
bytes < 1024 -> "#{bytes} B"
bytes < 1024 * 1024 -> "#{Float.round(bytes / 1024, 1)} KB"
bytes < 1024 * 1024 * 1024 -> "#{Float.round(bytes / (1024 * 1024), 1)} MB"
true -> "#{Float.round(bytes / (1024 * 1024 * 1024), 1)} GB"
end
end
defp maybe_add_error(acc, error_types, type, message) do
if type in error_types, do: [message | acc], else: acc
end
defp maybe_remove_broker_error(errors, broker) when is_binary(broker) and broker != "" do
Enum.reject(errors, &(&1 == @error_no_broker))
end
defp maybe_remove_broker_error(errors, _broker), do: errors
defp remove_upload_errors(errors) do
Enum.reject(errors, &(&1 in [@error_not_accepted, @error_too_large, @error_too_many_files]))
end
defp validate_upload_errors(upload) do
upload.entries
|> Enum.reduce([], fn entry, acc ->
entry_error_types = upload_errors(upload, entry)
acc
|> maybe_add_error(entry_error_types, :not_accepted, @error_not_accepted)
|> maybe_add_error(entry_error_types, :too_large, @error_too_large)
|> maybe_add_error(entry_error_types, :too_many_files, @error_too_many_files)
end)
|> Enum.uniq()
|> Enum.reverse()
end
# Animations Functions
defp show(js \\ %JS{}, id) do
IO.inspect("called show with: #{id}")
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-backdrop",
transition: {"transition-all transform ease-out duration-500", "opacity-0", "opacity-100"}
)
|> JS.show(
to: "##{id}-panel",
time: 500,
transition: {"transition-all transform ease-out duration-500", "translate-x-full", "translate-x-0"}
)
|> JS.add_class("overflow-hidden", to: "body")
|> JS.focus_first(to: "##{id}-panel")
end
defp hide(js \\ %JS{}, id) do
IO.inspect("called hide with: #{id}")
js
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.hide(
to: "##{id}-backdrop",
transition: {"ease-in-out duration-300", "opacity-100", "opacity-0"}
)
|> JS.hide(
to: "##{id}-panel",
time: 500,
transition: {"transition-all transform ease-in-out duration-300", "translate-x-0", "translate-x-full"}
)
|> JS.hide(to: "##{id}", transition: {"duration-500", "", ""})
end
end