Statamic Peak

Article

File Upload with Elixir and Phoenix LiveView

We will learn together how to handle file upload with Elixir, Phoenix LiveView and TailwindCSS to build a wonderful experience.

File upload is a must do in every web project, and often turns out to be complicated when you start using a new framework: very technical documentation, examples found online are copy-pasted from the doc and not always official...

With Phoenix LiveView, we're lucky, the official documentation is complete but lacks of clarity for a complete beginner.

I will use a real example from some side project to show you how things are done !

Before we get started, I let you create a LiveView with its route, etc.

Setup the socket to allow upload

The first step is to allow file upload for our LiveView. We will simply modify our mount function by saying :

  • The parameter's name that will receive the uploaded image

  • The allowed extensions

  • And many other options

@impl true
  def mount(_params, _session, socket) do
    {
      :ok,
      socket
      |> allow_upload(:image, accept: ~w(.jpg .jpeg .png))
    }
  end

Integrate our upload's file

We will add a <.livefileinput> field in our .html.heex file or our render/1 file. This is a component create by Phoenix LiveView which will link our classic HTML input with our LiveView socket.

I will put it inside a <form> because it's the most common behaviour but you can also for example use the :auto_upload option from the allow_upload function.

To make things look nicer, i offer you an example using a free TailwindUI element, but things are perfectly working without.

<form
    id="upload-form"
    phx-change="validate-upload"
    phx-submit="upload"
  >
    <div class="mb-2">
      <label class="block text-sm font-medium text-gray-700">Image</label>
      <div class="mt-1 flex justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6" phx-drop-target={@uploads.image.ref}>
        <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">
            <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" />
          </svg>
          <div class="flex text-sm text-gray-600">
            <label class="relative cursor-pointer rounded-md bg-white font-medium text-primary-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2 hover:text-primary-500">
              <span>Upload a file</span>
              <.live_file_input upload={@uploads.image} class="sr-only" /> 
            </label>
            <p class="pl-1">or drag and drop</p>
          </div>
          <p class="text-xs text-gray-500">PNG or JPG</p>
        </div>
      </div>
    </div>
    <button phx_disable_with={"Saving..."}>Save</button>
</form>

I will skip the explanations for the TailwindCSS classes and HTML. We will focus on the Phoenix LiveView integration.

First, we added to our form the phx-change="validate-upload" and phx-submit="upload" attributes. These are the classical attributes from a Phoenix LiveView's form to handle data validation on upload. They refer to handle_event function in our LiveView that we will see in details soon.

Then, we have a phx-drop-target={@uploads.image.ref} attribute. @uploads is automatically injected by Phoenix and it contains all the allowed uploads. Reminder, we called it image in our example but we could have named it avatar or anything else. This attribute automatically handles the drag and drop from an image in that <div>, pretty neat !

Finally we have the input that is simply <.livefileinput upload={@uploads.image} class="sr-only" />.

Handle the form validation

On Elixir side, we need to add a function to handle the phx-change event. We will simply validate the file (size, file type, etc.) but if you have. many fields in the form, you will also validate the changeset there.

To validate the upload, nothing specific to do, it will be done automatically according to the parameters set in the allow_upload function. We only need a function handling the event and returning the socket.

@impl true
  def handle_event("validate-upload", _params, socket) do
    {:noreply, socket}
  end

Good job ! But what happens if an error occurs ? We will add, between our upload and our submit button, the errors and even the upload's progression, and, why not, also a preview.

<%= for entry <- @uploads.image.entries do %>
    <figure>
        <.live_img_preview entry={entry} />
        <figcaption><%= entry.client_name %></figcaption>
    </figure>
    <p>
        <progress value={entry.progress} max="100"> <%= entry.progress %>% </progress>
    </p>
    <%= for err <- upload_errors(@uploads.image, entry) do %>
        <p><%= error_to_string(err) %></p>
    <% end %>
<% end %>

LiveView is working the same, no matters if we allow one or many images, so we need to handle in a loop. I will show the example after putting more CSS into it, the actual result is a bit "gross".

Before going further, we just need the code for errortostring/1. When an error will occur during the upload, Phoenix gives us the information in an atom, so we will make simple functions to get a string instead.

def error_to_string(:too_large), do: "Image too large"
def error_to_string(:too_many_files), do: "Too many files"
def error_to_string(:not_accepted), do: "Unacceptable file type"

Save the file

In order to finish our tutorial, we only need to save the image on the server side.

@impl true
  def handle_event("upload", _params, socket) do
    file_path =
      consume_uploaded_entries(socket, :image, fn %{path: path}, _entry ->
        directory = Path.join([:code.priv_dir(:petal_pro), "static", "uploads"])
        File.mkdir(directory)
        dest = Path.join([directory, Path.basename(path)])
        File.cp!(path, dest)
        {:ok, Routes.static_path(socket, "/uploads/#{Path.basename(dest)}")}
      end)
    {:noreply, push_event(socket, "new_image", %{image: file_path})}
  end

Usually, we define a function to trigger on the upload event at the form submit. This functions will get the path and send it to my JS through an event. In the official documentation they stock the images in the socket's assign but you can do as you wish.

What's more interesting is the consumeuploadedentries function and the callback we give it. This function will handle the uploaded files save. In our situation, we will stock them in the /priv/static/uploads folder and create it if it doesn't exist. The last line means we want the image treatment to happen immediately and we build an URL linking to the new file.

If you test your form, everything will work, but you might not be able to render in a <img> the file you just saved.

Check the Plug.Static configuration

There's a chance that the URL previously received won't allow you to visualize the image in your browser. That's because you need to define in your endpoint.ex the folders that Plug.Static must show.

In my case, it lookes like, with the manually added uploads folder.

  plug Plug.Static,
    at: "/",
    from: :my_app,
    gzip: false,
    only: ~w(assets fonts images uploads favicon.ico robots.txt)

And now everything works ! 🥳