Web forms have different complexity levels, and they become even more complex with uploads. LiveView makes it simple to work with forms, but sometimes you have a use case that is not available in the official documentation. One such use case is:

How to upload multiple files with different names in a LiveView form?

None

A reader of my book Ash Framework for Phoenix Developers asked me how to upload multiple files in a single form using LiveView forms. And that's what I'm showing you today.

We'll start by using LiveView to consume uploads; later, we'll improve our implementation to move the upload consumption logic to an Ash generic action.

If you're looking to dive deeper into the Ash Framework, grab my book Ash Framework for Phoenix Developers.

Let's get started.

Assume we have a resource called MyApp.Profiles.Case where a case has file 1 and file 2 attributes.

# lib/my_app/profiles/case.ex
defmodule MyApp.Profiles.Case do
  use Ash.Resource,
    domain: MyApp.Profiles,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "profile_cases"
    repo Imdent.Repo
  end

  actions do
    defaults [:read]

    create :create do
      description "Create a new case for an existing profile"
      accept [:title, :file_1, :file_2]
    end
  end

  validations do
    validate string_length(:title, max: 150) do
      message "Title must be less than 150 character"
    end
  end

  attributes do
    uuid_primary_key :id
    attribute :title, :string

    attribute :file_1, :string do
      public? true
    end

    attribute :file_2, :string do
      public? true
    end

    timestamps()
  end

  # I omitted relationship to the profile for simplicity purpose
end

Then we'll define the following liveview form:

# lib/my_app_web/live/profiles/cases/form_live.ex
defmodule MyAppWeb.Profiles.Cases.FormLive do
  use MyAppWeb, :live_view

  @impl Phoenix.LiveView
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        {"A form with multiple files"}
        <:subtitle>Use this form to add information and multiple files.</:subtitle>
      </.header>

      <.form for={@form} phx-submit="save" phx-change="validate">
        <.input field={@form[:title]} label={gettext("Title")} />

      <div class="flex gap-2 mb-2">
      <div class="w-full">
        <label class="block text-teal-600">{gettext("Upload file 1")}</label>
        <div for={@uploads.file_1.ref} class="block border border-dashed p-2 rounded-sm">
          <.live_file_input upload={@uploads.file_1} />
        </div>
      </div>

      <div class="W-full">
        <label class="block text-teal-600">{gettext("Upload file 2")}</label>
        <div for={@uploads.file_2.ref} class="block border border-dashed p-2 rounded-sm">
          <.live_file_input upload={@uploads.file_2} />
        </div>
      </div>
      </div>
      <.button variant="primary">{gettext("Submit")}</.button>
      </.form>
    </Layouts.app>
    """
  end

  def mount(_params, _sessions, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files, [])
     |> allow_upload(:file_1, accept: ~w(.jpg .jpeg), max_entries: 1)
     |> allow_upload(:file_2, accept: ~w(.jpg .jpeg), max_entries: 1)
     |> assign_form()}
  end

  @impl Phoenix.LiveView
  def handle_event("save", %{"form" => params}, socket) do
    [uploaded_before_media] = upload_file(socket, :file_1)
    [uploaded_after_media] = upload_file(socket, :file_2)

    form_params =
      params
      |> Map.put("file_1", uploaded_before_media)
      |> Map.put("file_2", uploaded_after_media)

   case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
      {:ok, profile} ->
        {:noreply, put_flash(socket, :info, gettext("Case added")))}

      {:error, form} ->
        {:noreply,
         socket
         |> put_flash(:error, gettext("Unable to add a case"))
         |> assign(:form, form)
        }
    end
  end

  # Required handle event for live upload to work.
  @impl Phoenix.LiveView
  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end
 
 # Add form to the socket.
 defp assign_form(socket) do
    form =
      MyApp.Profiles.Case
      |> AshPhoenix.Form.for_create(:create)
      |> to_form()

    assign(socket, :form, form)
  end

  @doc """
  Uploads file in the socket based on the name. This is name has to be matching one of
  the file name provided in the `allow_upload/3` function call in the mount
  """
  def upload_file(socket, name) when is_atom(name) do
    consume_uploaded_entries(socket, name, fn %{path: path}, _entry ->
      dest = Path.join(Application.app_dir(:my_app, "priv/static/uploads"), Path.basename(path))
      # You will need to create `priv/static/uploads` for `File.cp!/2` to work.
      File.cp!(path, dest)
      {:ok, ~p"/uploads/#{Path.basename(dest)}"}
    end)
  end

end

Let's explain what's going on here.

In the mount/3 function we told Liveview that we want to upload two files, file_1 and file_2 like the following:

socket
# Tell liveview that we will be uploading multiple files
|> assign(:uploaded_files, [])
# Tell live view what type of file types we'll accept and how many
# This makes it possible to upload file 1
|> allow_upload(:file_1, accept: ~w(.jpg .jpeg), max_entries: 1)
# We add the following file 2 upload 
|> allow_upload(:file_2, accept: ~w(.jpg .jpeg), max_entries: 1)

The allow_upload/3 function is chainable and you can allow uploads for as many files as you like.

Next in the render/1 function we defined the form with the file name matching the allowed uploads in the mount :

<div for={@uploads.file_1.ref} class="block border border-dashed p-2 rounded-sm">
  <.live_file_input upload={@uploads.file_1} />
</div>

The :file_1 in the mount/3 function is what we've used in the <.live_file_input upload={@uploads.file_1} /> making it possible to upload this file.

The same thing was done for file_2.

Next, we extracted the consumption of the upload entries in a different function so that we can be able to individually upload different files from the same form and attach them to the form attribute before saving the data to the database.

@doc """
 Uploads file in the socket based on the name. This is name has to be matching one of
 the file name provided in the `allow_upload/3` function call in the mount
"""
def upload_file(socket, name) when is_atom(name) do
   consume_uploaded_entries(socket, name, fn %{path: path}, _entry ->
   dest = Path.join(Application.app_dir(:my_app, "priv/static/uploads"), Path.basename(path))

   # You will need to create `priv/static/uploads` for `File.cp!/2` to work.
   File.cp!(path, dest)

   {:ok, ~p"/uploads/#{Path.basename(dest)}"}
  end)
end

The name parameter in the upload_file/2 function must be one of :file_1 or :file_2 since that's what we've defined in the allowed_upload in the mount/3 function.

Next we need to upload the files to the server, then save the file path only.

The file path is the address we need in the future to download, to display(if it is a picture) or to process in any other way we desire.

Therefore the proper place to do this is in the handle_event/3 function.

@impl Phoenix.LiveView
  def handle_event("save", %{"form" => params}, socket) do
    # 1. Begin by upload each file individually
    [uploaded_file_1] = upload_file(socket, :file_1)
    [uploaded_file_2] = upload_file(socket, :file_2)

    # 2. Add file path to the form before saving in the database
    form_params =
      params
      |> Map.put("file_1", uploaded_file_1)
      |> Map.put("file_2", uploaded_file_2)

    # 3. Save the form with file 1 & file 2 paths
    case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
      {:ok, profile} ->
        {:noreply, put_flash(socket, :info, gettext("Case added")))}

      {:error, form} ->
        {:noreply,
         socket
         |> put_flash(:error, gettext("Unable to add a case"))
         |> assign(:form, form)
        }
    end
  end

This is all you need to upload multiple files in a single form in a Liveview.

But you can go further if you are using Ash Framework.

What we did above is fine if you are having one form that needs file upload. But what if you need to upload files in different forms?

You will need to repeat this logic over and over again. You can minimise the repetition by extracting this logic into a generic action in Ash.

Let's see how to do this.

We'll start by creating MyApp.Files.File resource that will host the generic action. We won't add attributes to this resource because we don't need them. We just want to centralise the logic for consuming upload entries into one place for portability and future reuse.

MyApp.Files.File would be a good resource for saving files if we want to centralise the file management in our application. But for now, we don't have to go that far.

# lib/my_app/files/file.ex
defmodule MyApp.Files.File do
  use Ash.Resource,
    domain: MyApp.Files

  # Code interface to simply the calling of the upload action
  code_interface do
    define :upload do
      action :upload
      args [:socket, :upload_name]
    end
  end

  actions do
    action :upload, :string do
      description "Uploads file to the server and returns server path"

      argument :socket, :map do
        description "Liveview socket used to upload the file"
        allow_nil? false
      end

      argument :upload_name, :atom do
        description "Liveview upload name e.g. :avatar, :picture"
        allow_nil? false
      end

      run MyApp.Files.File.Actions.UploadFile
    end
  end
end

Next, add MyApp.Files domain:

# lib/my_app/files.ex
defmodule MyApp.Files do
  use Ash.Domain

  resources do
    resource MyApp.Files.File
  end
end

And have it configured in the config/config.exs like the following:

config :my_app,
  ecto_repos: [MyApp.Repo],
  generators: [timestamp_type: :utc_datetime],
  ash_domains: [ MyApp.Files] # You can add more here...

Then we'll add the generic action MyApp.Files.File.Actions.UploadFile to host the logic for consuming file upload entrires like the following:

# lib/my_app/files/file/actions/upload_file.ex
defmodule MyApp.Files.File.Actions.UploadFile do
  use Ash.Resource.Actions.Implementation

  def run(input, _opts, _context) do
    socket = input.arguments.socket
    upload_name = input.arguments.upload_name

    uploads =
      Phoenix.LiveView.consume_uploaded_entries(socket, upload_name, fn %{path: path}, _entry ->
        dest = Path.join(Application.app_dir(:my_app, "priv/static/uploads"), Path.basename(path))
        # You will need to create `priv/static/uploads` for `File.cp!/2` to work.
        File.cp!(path, dest)
        {:ok, "/uploads/#{Path.basename(dest)}"}
      end)

    {:ok, uploads}
  end
end

Finally, we'll refactor the handle_event/3 responsible for saving the record to the database to look like the following:

# lib/my_app_web/live/profiles/cases/form_live.ex
defmodule MyAppWeb.Profiles.Cases.FormLive do
  use MyAppWeb, :live_view
  # Other liveview code...

  @impl Phoenix.LiveView
  def handle_event("save", %{"form" => params}, socket) do
    # Use the centralised logic for uploading files to the server
    [uploaded_before_media] = MyApp.Files.File.upload!(socket, :file_1)
    [uploaded_after_media] = MyApp.Files.File.upload!(socket, :file_2)

    form_params =
      params
      |> Map.put("file_1", uploaded_before_media)
      |> Map.put("file_2", uploaded_after_media)

    case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
      {:ok, profile} ->
        {:noreply, put_flash(socket, :info, gettext("Case added")))}

      {:error, form} ->
        {:noreply,
         socket
         |> put_flash(:error, gettext("Unable to add a case"))
         |> assign(:form, form)
        }
    end
  end
end

With the above changes, the code should continue to run as expected but this time:

  1. The codes look more clean.
  2. The consumption of uploaded entries is centralised and can be reused in multiple forms in in the application by calling MyApp.Files.File.upload!(socket, :FILE_NAME_HERE) .
  3. The code is easier to maintain and improve as the logic is centralised in one place.

For simplicity purpose, there're things we didn't cover in this article such as How to delete the uploaded, but unused files from the disk, however I hope this gave you an understanding of how to upload multiple files with in the same form in LiveView.

To recap we saw:

  1. How to upload multiple files with different names in LiveView.
  2. How to use Ash generic action for centralising the app logic.

Let me know in the comment how you've been uploading multiple files from a single form.