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:
[arcade] ➔ mix ecto.gen.migration add_image_to_products
* 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:
defmodule Arcade.Repo.Migrations.AddImageToProducts do
use Ecto.Migration
def change do
alter table(:products) do
add :image_upload, :string
end
end
end
This code will add the new database field when we run the migration. Let's do that now:
[arcade] ➔ mix ecto.migrate
[info] == Running 20201231152152 \
Arcade.Repo.Migrations.AddImageToProducts.change/0 forward
10:22:24.034 [info] alter table products
10: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:
defmodule Arcade.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :description, :string
field :name, :string
field :sku, :integer
field :unit_price, :float
field :image_upload, :string
timestamps()
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :description, :unit_price, :sku, :image_upload])
|> validate_required([:name, :description, :unit_price, :sku])
|> validate_number(:unit_price, greater_than: 0)
|> unique_constraint(:sku)
end
end
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:
defmodule ArcadeWeb.ProductLive.FormComponent do
use ArcadeWeb, :live_component
alias Arcade.Product
@impl true
def update(%{product: product} = assigns, socket) do
changeset = Product.changeset(product, %{})
{:ok, socket
|> assign(assigns)
|> assign(:changeset, changeset)
|> allow_upload(:image, accept: ~w(.jpg .jpeg .png), max_entries: 1)}
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:
%{
# ...
uploads: %{
__phoenix_refs_to_names__: %{"phx-FlZ_j-hPIdCQuQGG" => :image},
image: #Phoenix.LiveView.UploadConfig<
accept: ".jpg,.jpeg,.png",
entries: [],
errors: [],
max_entries: 1,
max_file_size: 8000000,
name: :image,
progress_event: #Function<1.71870957/3 ...>,
ref: "phx-FlZ_j-hPIdCQuQGG",
...
>
}
}
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:
#Phoenix.LiveView.UploadConfig<
accept: ".jpg,.jpeg,.png",
entries: [],
errors: [],
max_entries: 1,
max_file_size: 8000000,
name: :image,
progress_event: #Function<1.71870957/3 ...>,
ref: "phx-FlZ_j-hPIdCQuQGG",
...
>
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:
<%= f = form_for @changeset, "#",
id: "product-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save" %>
<%= label f, :name %>
<%= text_input f, :name %>
<%= error_tag f, :name %>
<%= label f, :description %>
<%= text_input f, :description %>
<%= error_tag f, :description %>
<%= label f, :unit_price %>
<%= number_input f, :unit_price, step: "any" %>
<%= error_tag f, :unit_price %>
<%= label f, :sku %>
<%= number_input f, :sku %>
<%= error_tag f, :sku %>
<% # File upload fields here: %>
<%= label f, :image %>
<%= live_file_input @uploads.image %>
<%= submit "Save", phx_disable_with: "Saving..." %>
</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:
And if you inspect the element, you'll see that the live_file_input/2
function generated the appropriate 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:
<%= f = form_for @changeset, "#",
id: "product-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save" %>
# ...
<div phx-drop-target="<%= @uploads.image.ref %>">
<%= live_file_input @uploads.image %>
</div
# ...
</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:
defmodule ArcadeWeb.ProductLive.FormComponent do
# ...
def handle_event("save", product_params, socket) do
file_path =
consume_uploaded_entry(socket, :image, fn %{path: path}, _entry ->
dest = Path.join("priv/static/uploads", Path.basename(path))
File.cp!(path, dest)
Routes.static_path(socket, "/uploads/#{Path.basename(dest)}")
end)
product = save_product(Map.put(product_params, :image_upload, file_path)
{:noreply,
socket
|> push_redirect(to: Routes.product_show_path(socket, :show, product))}
end
end
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:
<article class="column">
<img
alt="product image" width="200" height="200"
src="<%=Routes.static_path(
@socket,
@product.image_upload || "/images/default-thumbnail.jpg")%>">
</article>
<!-- product details... -->
Perfect. Now, we can test drive it. Visit /products/1/edit
and upload a file:
Once you submit the form, you'll see the show page render the newly uploaded image, like this:
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:
<%= for entry <- @uploads.image.entries do %>
<p>
<progress value={entry.progress} max="100"> <%= entry.progress %>% </progress>
</p>
<% 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:
<%= for err <- upload_errors(@uploads.image, entry) do %>
<p class="alert alert-danger"><%= friendly_error(err) %></p>
<% 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:
defmodule ArcadeWeb.ProductLive.FormComponent do
# ...
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"
end
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!
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. You can find her on Twitter or check out her blog.
Top comments (0)