Auto-closing drawer LiveComponent after file upload

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

1 Like

Can you elaborate on what is the actual issue? This can mean several things. Can you tell us what is the expected behavior and what is happening instead?

1 Like

My expectation is that once the def handle_event(“upload”, %{“broker” => broker, “timezone” => timezone}, socket) successfully completes, it calls the hide() function to hide the panel and its backdrop.

I have tried the following at the end of my upload event but was not successful:

socket = assign(socket, is_uploader_visible?, false)
hide(socket.assigns.id)
{:noreply, socket}

If you look at the close button it is doing exactly what I expect

<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>

I have not used these JS commands, I have an allergic reaction when I see this, but I suspect I know what the problem is.

socket = assign(socket, is_uploader_visible?, false)
hide(socket.assigns.id)
{:noreply, socket}

hide/1 function executes and… that’s about it. It executes, outputs a %JS{} structure or whatever it does, and this ends up doing nothing as it’s not sent down to the browser in any way. I think that’s what’s happening, but I’d need to read up on these JS commands in LV.

I looked but I have no idea how to execute these JS commands from a handle_event callback. Maybe it’s not allowed. You for sure can push an event to the client-side with push_event JavaScript interoperability — Phoenix LiveView v1.0.11

I really appreciate that you looked into it @hubertlepicki. I will continue digging. Thank you.

JS commands are client-side by design, they can’t be executed by the server. Their purpose is to do things on the client without going through the server, but where those “things” are defined by the server when it sends the rendered content. If you want to trigger JS on the client via the server, the guide you linked contains the correct approach: push_event() on the server and liveSocket.execJS() on the client.

Note that in this case, though, you can actually execute the JS on the client on submit:

<.form
  phx-submit={hide(@id) |> JS.push("upload")}
/>

Thereby avoiding the round-trip, which is the purpose of JS commands :slight_smile:

BTW, is there a reason you are calling your show/hide functions in phx-mounted and phx-remove? I don’t really see what that accomplishes here.

1 Like

I use them to enforce the drawer opened / hidden states from the parent LiveView. These are temporary and save me some clicks when testing in the browser.

If you’re looking to execute client-side code from the server, check out this article from Fly.io.

TLDR:
Add this to your app.js:

window.addEventListener("phx:js-exec", ({detail}) => {
    document.querySelectorAll(detail.to).forEach(el => {
        liveSocket.execJS(el, el.getAttribute(detail.attr))
    })
  });

then add a data attribute on the client-side for the event:

<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) |> JS.hide(@id)}
   data-hide={JS.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>

and then trigger it on the server side:

push_event(socket, "js-exec", %{
  to: "#BUTTON-ID-HERE", 
  attr: "data-hide"
})
3 Likes

That was execatly, what I need. I am very grateful for your answer and your help because it unblocked me. Thank you @absowoot

1 Like