elixir

How to Do Live Uploads in Phoenix LiveView

Sophie DeBenedetto

Sophie DeBenedetto on

How to Do Live Uploads in Phoenix LiveView

The LiveView framework supports all of the most common features that Single-Page Apps must offer their users, including multipart uploads. In fact, LiveView can give us highly interactive file uploads, right out of the box.

In this post, we'll add a file upload feature to an existing Phoenix LiveView application. Along the way, you'll learn how to use LiveView to display upload progress and feedback while editing and saving uploaded files.

Setting Up the Image Upload Feature

The live upload examples that we'll be looking at in this post are drawn from the "Forms and Changesets" chapter in my book, Programming LiveView, co-authored with Bruce Tate. Check it out for an even deeper dive into LiveView forms and so much more.

In this example, we have an online game store — Arcade — that allows users to browse and review products. Admins can access a product management interface to create, edit, and delete the products we offer our users. We'll give admins the ability to upload a product image that is then stored in the database along with the given product. Let's plan out our new image upload feature before we start writing any code.

We'll begin in our application core by adding an image_upload field to the table and schema for products. Then, we'll create a LiveView form component that supports file uploads. Finally, we'll teach our component to report on upload progress and other bits of upload feedback.

This post will focus on adding the live upload functionality to an existing LiveView app that already implements:

  • A live view for displaying products.
  • A LiveView component that contains a form for creating/editing products.

We'll zero in on the code required to add the upload functionality to this form.

Check out my earlier post for a basic introduction to working with forms in LiveView.

Now we're ready to write some code.

Persist Product Images in Phoenix LiveView

Assuming our Phoenix LiveView app already has a products table and Product schema, we'll now update both to store an image_upload attribute. This attribute will point to the location of the uploaded file. Once we have our backend wired up, we'll be able to update the existing live view form to accommodate file uploads for a product.

We'll start at the database layer by generating a migration to add a field, :image_upload, to the products table.

First, generate your migration file:

1[arcade] ➔ mix ecto.gen.migration add_image_to_products
2* creating priv/repo/migrations/20201231152152_add_image_to_products.exs

This creates a migration file for us, priv/repo/migrations/20201231152152_add_image_to_products.exs. Open up that file and key in the contents to the change function:

1defmodule Arcade.Repo.Migrations.AddImageToProducts do
2  use Ecto.Migration
3
4  def change do
5    alter table(:products) do
6      add :image_upload, :string
7    end
8  end
9end

This code will add the new database field when we run the migration. Let's do that now:

1[arcade] ➔ mix ecto.migrate
2
3[info]  == Running 20201231152152 \
4
5Arcade.Repo.Migrations.AddImageToProducts.change/0 forward
610:22:24.034 [info]  alter table products
7
810:22:24.041 [info]  == Migrated 20201231152152 in 0.0s

This migration added a new column — :image_upload — of type :string to the products table, but our schema still needs attention.

Update the corresponding Product schema by adding the new :image_upload field to the schema function, which should look like this:

1defmodule Arcade.Catalog.Product do
2  use Ecto.Schema
3  import Ecto.Changeset
4
5  schema "products" do
6    field :description, :string
7    field :name, :string
8    field :sku, :integer
9    field :unit_price, :float
10    field :image_upload, :string
11    timestamps()
12  end
13
14  @doc false
15  def changeset(product, attrs) do
16    product
17    |> cast(attrs, [:name, :description, :unit_price, :sku, :image_upload])
18    |> validate_required([:name, :description, :unit_price, :sku])
19    |> validate_number(:unit_price, greater_than: 0)
20    |> unique_constraint(:sku)
21  end
22end

Remember, the changeset cast/4 function must explicitly whitelist new fields, so make sure you add the :image_upload attribute there, as shown above.

Now that the changeset has an :image_upload attribute, we can save product records that know their image upload location. With that in place, we can make an image upload field available in the ProductLive.FormComponent's form. We're one step closer to giving users the ability to save products with images.

Now, let's turn our attention to the component.

How to Allow Live Uploads

We'll see our product changeset in action in a bit. First, we need to give the product form the ability to support file uploads. In our Phoenix application, both the "new product" and "edit product" pages use the ProductLive.FormComponent. This provides one centralized place to maintain our product form. Changes to this component will enable users to upload an image for a new product and a product they are editing.

To enable uploads for our component, or any live view, we need to call the allow_upload/3 function with a first argument of the socket. This will put the data into socket assigns that the LiveView framework will then use to perform file uploads. So, for a component, we'll call allow_upload/3 when the component first starts up and establishes its initial state in the update/2 function. For a live view, we'd call allow_upload/3 in the mount/3 function.

The allow_upload/3 function is a reducer that takes in an argument of the socket, the upload name, and the upload options and returns an annotated socket. Supported options include file types, file size, number of files per upload name, and more. Let's see it in action:

1defmodule ArcadeWeb.ProductLive.FormComponent do
2  use ArcadeWeb, :live_component
3  alias Arcade.Product
4
5  @impl true
6  def update(%{product: product} = assigns, socket) do
7    changeset = Product.changeset(product, %{})
8    {:ok, socket
9      |> assign(assigns)
10      |> assign(:changeset, changeset)
11      |> allow_upload(:image, accept: ~w(.jpg .jpeg .png), max_entries: 1)}
12  end

In allow_upload/3, we pipe in a socket and specify a name for our upload: :image. We also provide some options — the maximum number of permitted files and the accepted file formats.

Let's take a look at what our socket assigns looks like after allow_upload/3 is invoked:

1%{
2  # ...
3  uploads: %{
4    __phoenix_refs_to_names__: %{"phx-FlZ_j-hPIdCQuQGG" => :image},
5    image: #Phoenix.LiveView.UploadConfig<
6      accept: ".jpg,.jpeg,.png",
7      entries: [],
8      errors: [],
9      max_entries: 1,
10      max_file_size: 8000000,
11      name: :image,
12      progress_event: #Function<1.71870957/3 ...>,
13      ref: "phx-FlZ_j-hPIdCQuQGG",
14      ...
15    >
16  }
17}

The socket now contains an :uploads map that specifies the configuration for each upload field your live view allows. We allowed uploads for an upload called :image. So our map contains a key of :image pointing to a value of the configuration constructed using the options we gave allow_upload/3. This means that we can add a file upload field called :image to our form, and LiveView will track the progress of files uploaded via the field within socket.assigns.uploads.image.

You can call allow_upload/3 multiple times with different upload names, thus allowing any number of file uploads in a given live view or component. For example, you could have a form that allows a user to upload a main image, a thumbnail image, a hero image, and more.

Now that we've set up our uploads state, let's take a closer look at the :image upload configuration.

Upload Configurations in Phoenix LiveView

The :image upload config looks something like this:

1#Phoenix.LiveView.UploadConfig<
2  accept: ".jpg,.jpeg,.png",
3  entries: [],
4  errors: [],
5  max_entries: 1,
6  max_file_size: 8000000,
7  name: :image,
8  progress_event: #Function<1.71870957/3 ...>,
9  ref: "phx-FlZ_j-hPIdCQuQGG",
10  ...
11>

Notice that it contains the configuration options we passed to allow_upload/3: the accepted file types list and file formats.

It also has an attribute called :entries, which points to an empty list. When a user uploads a file for the :image form field, LiveView will automatically update this list with the file upload entry as it completes.

Similarly, the :errors list starts out empty and is automatically populated by LiveView with any errors from an invalid file upload entry.

In this way, the LiveView framework does the work of performing the file upload and tracking its state for you. We'll see both of these attributes in action in a bit.

Now that we've allowed uploads in our component, we're ready to update the template with the file upload form field.

Render the File Upload Field

You'll use the Phoenix.LiveView.Helpers.live_file_input/2 function to generate the HTML for a file upload form field. Here's a look at our form component template:

1<%= f = form_for @changeset, "#",
2          id: "product-form",
3          phx_target: @myself,
4          phx_change: "validate",
5          phx_submit: "save" %>
6
7  <%= label f, :name %>
8  <%= text_input f, :name %>
9  <%= error_tag f, :name %>
10
11  <%= label f, :description %>
12  <%= text_input f, :description %>
13  <%= error_tag f, :description %>
14
15  <%= label f, :unit_price %>
16  <%= number_input f, :unit_price, step: "any" %>
17  <%= error_tag f, :unit_price %>
18
19  <%= label f, :sku %>
20  <%= number_input f, :sku %>
21  <%= error_tag f, :sku %>
22
23  <% # File upload fields here: %>
24  <%= label f, :image %>
25  <%= live_file_input @uploads.image %>
26
27  <%= submit "Save", phx_disable_with: "Saving..." %>
28</form>

Notice the use of live_file_input/2 with an argument of @uploads.image. Remember, socket.assigns has a map of uploads. Here, we provide @uploads.image to live_file_input/2 to create a form field with the right configuration and tie that form field to the correct part of socket state. This means that LiveView will update socket.assigns.uploads.image with any new entries or errors that occur when a user uploads a file via this form input.

The live view can present upload progress by displaying data from @uploads.image.entries and @uploads.image.errors. LiveView will handle all of the details of uploading the file and updating socket assigns @uploads.image entries and errors for us. All we have to do is render the data that is stored in the socket. We'll take that on soon.

Now, we should be able to see the file upload field displayed in the browser like this:

file upload field

And if you inspect the element, you'll see that the live_file_input/2 function generated the appropriate HTML:

file upload button

file upload html

You can see that the generated HTML has the accept=".jpg,.jpeg,.png" attribute set, thanks to the options we passed to allow_upload/3.

LiveView also supports drag-and-drop file uploads. All we have to do is add an element to the page with the phx-drop-target attribute, like this:

1<%= f = form_for @changeset, "#",
2          id: "product-form",
3          phx_target: @myself,
4          phx_change: "validate",
5          phx_submit: "save" %>
6
7  # ...
8
9  <div phx-drop-target="<%= @uploads.image.ref %>">
10    <%= live_file_input @uploads.image %>
11  </div
12
13  # ...
14</form>

We give the attribute a value of @uploads.image.ref. This socket assignment is the ID that LiveView JavaScript uses to identify the file upload form field and tie it to the correct key in socket.assigns.uploads. So now, if a user clicks the "choose file" button or drags-and-drops a file into the area of this div, LiveView will store the file info in the socket.assigns.uploads assignment, under the name of the specified upload, in that upload's :entries list.

As with other form interactions, LiveView automatically handles the client/server communication. When the user submits the form, LiveView's JavaScript will first upload the file(s) and then invoke the handle_event/3 callback for the form's phx-submit event. To process the file upload, this event handler will need to consume the file upload stored in socket.assigns.uploads.image.entries. Let's do that now.

Consume Uploaded Entries

Our handle_event/3 function for the phx_submit: "save" form event will use LiveView's consume_uploaded_entry/3 function to process the uploaded file. For now, we'll have our function write the uploaded file to our app's static assets in priv/static/images. This is so that we can display it on the product show template later on.

Here's our code:

1defmodule ArcadeWeb.ProductLive.FormComponent do
2# ...
3  def handle_event("save", product_params, socket) do
4    file_path =
5      consume_uploaded_entry(socket, :image, fn %{path: path}, _entry ->
6        dest = Path.join("priv/static/uploads", Path.basename(path))
7        File.cp!(path, dest)
8        Routes.static_path(socket, "/uploads/#{Path.basename(dest)}")
9      end)
10
11    product = save_product(Map.put(product_params, :image_upload, file_path)
12
13    {:noreply,
14      socket
15      |> push_redirect(to: Routes.product_show_path(socket, :show, product))}
16  end
17end

We save the image to static assets and return the file path from consume_uploaded_entry/3. Then, we call a helper function — save_product/1 (not pictured here) — to update the product with the given form params, including the new :image_upload attribute set to our new file path. Finally, we redirect to the Product Show page.

To see our code in action, let's add some markup to the product show template to display image uploads. Then, we'll try out our feature.

Display Image Uploads

Open up lib/arcade_web/live/product_live/show.html.leex and add the following markup to display the uploaded image or a fallback:

1<article class="column">
2   <img
3      alt="product image" width="200" height="200"
4      src="<%=Routes.static_path(
5              @socket,
6              @product.image_upload || "/images/default-thumbnail.jpg")%>">
7  </article>
8<!-- product details... -->

Perfect. Now, we can test drive it. Visit /products/1/edit and upload a file:

edit product image

Once you submit the form, you'll see the show page render the newly uploaded image, like this:

product show image

We did it! The LiveView framework handled all of the client/server communication details that make the page interactive. LiveView performed the file upload for you and made responding to upload events easy and customizable. You only needed to tell the live view which uploads to track and what to do with uploaded files when the form is submitted. Then you added the file upload form field to the page with the view helper and LiveView handled the rest!

There's one last thing to do. Earlier, I promised reactive file uploads that share feedback with the user. Let's take a look at this now.

Display Upload Feedback in Phoenix LiveView Forms

We know that LiveView automatically updates the :entries and :errors lists in the uploads config portion of socket.assigns once the upload begins. Let's display this information in the template to give the user real-time progress tracking. The code is amazingly simple. We'll iterate over @uploads.image.entries to display the progress for each entry:

1<%= for entry <- @uploads.image.entries do %>
2  <p>
3    <progress value={entry.progress} max="100"> <%= entry.progress %>% </progress>
4  </p>
5<% end %>

Here, we use the HTML progress tag to create a simple progress bar that is populated with the progress of our file upload in real-time. As LiveView's JavaScript is uploading the file for you, LiveView is updating the value of the entry's progress in socket assigns. This causes the relevant portion of the template to re-render, thereby showing the updated progress bar in real-time. LiveView handles the work of tracking the changes to the image entry's progress. All we have to do is display it.

You can use a similar approach to iterate over and display any errors stored in @uploads.image.errors. You won't have to do any work to validate files and populate errors. LiveView handles those details. All you need to do is display any errors based on the needs of your user interface. Here's a look at the code:

1<%= for err <- upload_errors(@uploads.image, entry) do %>
2  <p class="alert alert-danger"><%= friendly_error(err) %></p>
3<% end %>

Here, we use the Phoenix.LiveView.Helpers.upload_errors/2 function to return the errors for the specified upload.

The error messages aren't very user-friendly, though. So, we'll implement a helper function, friendly_error/1, in our LiveView component that looks like this:

1defmodule ArcadeWeb.ProductLive.FormComponent do
2  # ...
3
4  def friendly_error(:too_large), do: "Image too large"
5  def friendly_error(:too_many_files), do: "Too many files"
6  def friendly_error(:not_accepted), do: "Unacceptable file type"
7end

There's more that LiveView file uploads can do. LiveView makes it easy to:

  • cancel an upload
  • upload multiple files for a given upload config
  • upload files directly from the client to a cloud provider

and more.

Check out the LiveView file upload documentation for details.

Wrap-up: Build Complex Forms Easily with Phoenix LiveView

LiveView enables reactive file uploads right out of the box. Without writing any JavaScript, or even any custom HTML, you can build interactive file upload forms directly into your live view.

LiveView handles the details of client/server communication and upload state management, leaving you on the hook to write a very small amount of custom code specifying how your uploads should behave and how uploaded files should be saved.

This is a pattern you'll see again and again in LiveView — the framework handles the communication and state management details of our SPA, and we can focus on writing application-specific code to support our features.

Now that you've had a glimpse of what LiveView can do with form uploads, you're ready to build complex, interactive forms that support real-time uploads in the wild. Happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Share this article

RSS
Sophie DeBenedetto

Sophie DeBenedetto

Our guest author Sophie is a Senior Engineer at GitHub, co-author of Programming Phoenix LiveView, and co-host of the BeamRad.io podcast. She has a passion for coding education. Historically, she is a cat person but will admit to owning a dog.

All articles by Sophie DeBenedetto

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps