Imagine you are writing an app to handle products for a marketplace. Each Product has up to 3 images associated with it. When editing a Product, you should be able to remove any of the already associated images and add new images to the Product, always respecting the limit of 3 images per product.
Quite common requirements, right?
And given that Phoenix has pretty good code generators and LiveView now has an amazing live_file_input
component that automates uploading files in an interactive way, it should be pretty easy to implement.
Well...
Keep reading to discover what I learned in the last couple of days trying to code this simple task.
The initial project and schema
Let's start by creating a new project with the latest Phoenix version. I use asdf
for this:
# Add plugins
asdf plugin-add erlang
asdf plugin-add elixir
# Install them
asdf install erlang 25.3
asdf global erlang 25.3
asdf install elixir 1.14.4-otp-25
asdf global elixir 1.14.4-otp-25
# Install Phoenix 1.7
mix local.rebar --force
mix local.hex --force
mix archive.install hex phx_new 1.7.2 --force
We can now generate a project. I'll name mine mercury
:
mix phx.new mercury --install
We'll use a simple migration and schema for a Product with two attributes: name
and a list of images
, stored as an array of strings.
Phoenix code generators
Our first attempt is to use the Phoenix code generators:
mix phx.gen.live Products Product products name:string images:array:string
We'll see that the generated code for the images
attribute is an input field with type of select
(lib/mercury_web/live/product_live/form_component.ex):
<.input
field={@form[:images]}
type="select"
multiple
label="Images"
options={[{"Option 1", "option1"}, {"Option 2", "option2"}]}
/>
That's reasonable because the code generators have no way to know that we intended the array of strings to be image URLs and that the input should have a type of file
and be supported by the live_file_input
component.
As powerful as the generators are for CRUD pages, in this case, we need to do it by ourselves.
(Find the code for this part on the phoenix-code-generator
branch of the git repo)
Official live_file_input docs
Let's write the code for file uploads manually following the official docs for live_file_input
. We'll start from the generated code and we'll adapt it by adding the code from the official docs.
Add the following routes to router.ex
:
scope "/", MercuryWeb do
pipe_through :browser
get "/", PageController, :home
live "/products", ProductLive.Index
live "/products/new", ProductLive.New, :new
live "/products/:id", ProductLive.Show
live "/products/:id/edit", ProductLive.Edit, :edit
end
Replace the contents or create the following files.
lib/mercury_web/live/product_live/index.ex:
defmodule MercuryWeb.ProductLive.Index do
use MercuryWeb, :live_view
alias Mercury.Products
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, "Listing Products")
|> stream(:products, Products.list_products())
{:ok, socket}
end
end
lib/mercury_web/live/product_live/index.html.heex:
<.header>
Listing Products
<:actions>
<.link navigate={~p"/products/new"}>
<.button>New Product</.button>
</.link>
</:actions>
</.header>
<.table
id="products"
rows={@streams.products}
row_click={fn {_id, product} -> JS.navigate(~p"/products/#{product}") end}
>
<:col :let={{_id, product}} label="Name"><%= product.name %></:col>
<:action :let={{_id, product}}>
<div class="sr-only">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</div>
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</:action>
</.table>
lib/mixquic_web/live/artisan_courses_live/new.ex:
defmodule MercuryWeb.ProductLive.New do
use MercuryWeb, :live_view
alias Mercury.Products.Product
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, "New Product")
|> assign(:product, %Product{})
{:ok, socket}
end
end
lib/mercury_web/live/product_live/new.html.heex:
<.live_component
module={MercuryWeb.ProductLive.FormComponent}
id={:new}
title={@page_title}
action={@live_action}
product={@product}
navigate={~p"/products"}
/>
lib/mercury_web/live/product_live/edit.ex with this:
defmodule MercuryWeb.ProductLive.Edit do
use MercuryWeb, :live_view
alias Mercury.Products
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _url, socket) do
{:noreply,
socket
|> assign(:page_title, "Edit Course")
|> assign(:product, Products.get_product!(id))}
end
end
lib/mixquic_web/live/artisan_courses_live/edit.html.heex with:
<.live_component
module={MercuryWeb.ProductLive.FormComponent}
id={@product.id}
title={@page_title}
action={@live_action}
product={@product}
navigate={~p"/products"}
/>
lib/mercury_web/live/product_live/show.ex:
defmodule MercuryWeb.ProductLive.Show do
use MercuryWeb, :live_view
alias Mercury.Products
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, "Show course")
|> assign(:product, Products.get_product!(id))}
end
end
lib/mixquic_web/live/artisan_courses_live/show.html.heex:
<.header>
Product <%= @product.id %>
<:actions>
<.link navigate={~p"/products/#{@product}/edit"} phx-click={JS.push_focus()}>
<.button>Edit product</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Name"><%= @product.name %></:item>
</.list>
<div class="mt-4">
<.label>Images</.label>
<div class="grid justify-center md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-7 my-10">
<figure
:for={image <- @product.images}
class="rounded-lg border shadow-md max-w-xs md:max-w-none"
>
<img src={image} />
</figure>
</div>
</div>
<.back navigate={~p"/products"}>Back to products</.back>
lib/mercury_web/live/product_live/form_component.ex:
defmodule MercuryWeb.ProductLive.FormComponent do
use MercuryWeb, :live_component
alias Mercury.Products
@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.simple_form
for={@form}
id="product-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:name]} type="text" label="Name" />
<.label for="#images">Images</.label>
<div id="images">
<div
class="p-4 border-2 border-dashed border-slate-300 rounded-md text-center text-slate-600"
phx-drop-target={@uploads.images.ref}
>
<div class="flex flex-row items-center justify-center">
<.live_file_input upload={@uploads.images} />
<span class="font-semibold text-slate-500">or drag and drop here</span>
</div>
<div class="mt-4">
<.error :for={err <- upload_errors(@uploads.images)}>
<%= Phoenix.Naming.humanize(err) %>
</.error>
</div>
<div class="mt-4 flex flex-row flex-wrap justify-start content-start items-start gap-2">
<div
:for={entry <- @uploads.images.entries}
class="flex flex-col items-center justify-start space-y-1"
>
<div class="w-32 h-32 overflow-clip">
<.live_img_preview entry={entry} />
</div>
<div class="w-full">
<div class="mb-2 text-xs font-semibold inline-block text-slate-600">
<%= entry.progress %>%
</div>
<div class="flex h-2 overflow-hidden text-base bg-slate-200 rounded-lg mb-2">
<span style={"width: #{entry.progress}%"} class="shadow-md bg-slate-500"></span>
</div>
<.error :for={err <- upload_errors(@uploads.images, entry)}>
<%= Phoenix.Naming.humanize(err) %>
</.error>
</div>
<a phx-click="cancel" phx-target={@myself} phx-value-ref={entry.ref}>
<.icon name="hero-trash" />
</a>
</div>
</div>
</div>
</div>
<:actions>
<.button phx-disable-with="Saving...">Save Product</.button>
</:actions>
</.simple_form>
</div>
"""
end
@max_entries 3
@max_file_size 5_000_000
@impl true
def update(%{product: product} = assigns, socket) do
changeset = Products.change_product(product)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)
|> allow_upload(:images,
accept: ~w(.png .jpg .jpeg),
max_entries: @max_entries,
max_file_size: @max_file_size
)}
end
@impl true
def handle_event("validate", %{"product" => product_params}, socket) do
changeset =
socket.assigns.product
|> Products.change_product(product_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"product" => product_params}, socket) do
images =
consume_uploaded_entries(socket, :images, fn meta, entry ->
filename = "#{entry.uuid}#{Path.extname(entry.client_name)}"
dest = Path.join(MercuryWeb.uploads_dir(), filename)
File.cp!(meta.path, dest)
{:ok, ~p"/uploads/#{filename}"}
end)
product_params = Map.put(product_params, "images", images)
save_product(socket, socket.assigns.action, product_params)
end
def handle_event("cancel", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :images, ref)}
end
defp save_product(socket, :edit, product_params) do
case Products.update_product(socket.assigns.product, product_params) do
{:ok, _product} ->
{:noreply,
socket
|> put_flash(:info, "Product updated successfully")
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_product(socket, :new, product_params) do
case Products.create_product(product_params) do
{:ok, _product} ->
{:noreply,
socket
|> put_flash(:info, "Product created successfully")
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
end
lib/mercury_web.ex:
def static_paths, do: ~w(assets fonts images uploads favicon.ico robots.txt)
def uploads_dir, do: Application.app_dir(:mercury, ["priv", "static", "uploads"])
lib/mercury/application.ex:
@impl true
def start(_type, _args) do
MercuryWeb.uploads_dir() |> File.mkdir_p!()
# ...
end
Run migrations and start the server to try it:
mix ecto.migrate
mix phx.server
If you go now to http://localhost:4000/products
Create a new product:
Save it
and go to the show.html.heex
:
We are now able to create a product, add images to it, and display them. So far so good.
(Find the code for this part on the official-live-file-input-docs
branch of the git repo)
Editing the images of the product
Let's try to edit the product and change the images:
Now we see the issue. The existing images are not being shown the same way the product name
is. If you select one image and save it, this will replace the three ones currently associated with the product. If you don't have the original images available to upload them again when editing the product, they will be replaced and lost.
That's not good. We need to:
show the existing images if they exist
be able to remove some of the existing images if we want to
add new images to the product and save it
ensure that we don't exceed the max number of images allowed.
Let's implement each of those points
Showing existing images
Let's show the images of the product. The images to show are the ones currently associated to the product, not the entries in the :images
uploads map.
<.label for="#images">Images</.label>
<div id="images">
<div class="flex flex-row flex-wrap justify-start items-start text-center text-slate-500 gap-2 mb-8">
<div :for={image <- @product.images} class="border shadow-md pb-1">
<figure class="w-32 h-32 overflow-clip">
<img src={image} />
</figure>
</div>
</div>
Now we can see the images in the edit form:
Removing existing images and adding new images
To remove the existing images from the product we need to take into consideration a couple of things. First, when we remove them, we won't show them on the edit form anymore, but we don't really apply the changes to the product until we click the Save Product
button. We'll need to keep this in memory but not persist it until told so by the user.
We can use an removed_images
assign to hold the images the user has removed so far. It will start as an empty list and, because it is the initial state, we'll assign it on the mount
callback instead of the update
callback. Let's add it:
@impl true
def mount(socket) do
socket =
socket
|> assign(:removed_images, [])
{:ok, socket}
end
Now in the update
handler we can calculate which images to show:
@impl true
def update(%{product: product} = assigns, socket) do
changeset = Products.change_product(product)
images_to_show = product.images -- socket.assigns.removed_images
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)
|> assign(:images_to_show, images_to_show)
|> allow_upload(:images,
accept: ~w(.png .jpg .jpeg),
max_entries: @max_entries,
max_file_size: @max_file_size
)}
end
We add a new handler for the remove
event, that will be sent each time we remove an existing image from the Product:
@impl true
def handle_event("remove", %{"image" => image}, socket) do
product = socket.assigns.product
removed_images = [image | socket.assigns.removed_images]
images_to_show = product.images -- removed_images
socket =
socket
|> assign(:removed_images, removed_images)
|> assign(:images_to_show, images_to_show)
{:noreply, socket}
end
Let's tweak the template to correctly show the images and allow removing them:
<div :for={image <- @images_to_show} class="border shadow-md pb-1">
<figure class="w-32 h-32 overflow-clip">
<img src={image} />
</figure>
<a
phx-click="remove"
phx-target={@myself}
phx-value-image={image}
class="hover:text-slate-800"
>
<.icon name="hero-trash" />
</a>
</div>
We can now remove images and if we reload without saving the changes, the original images are shown.
Let's modify the save
event to correctly save the new set of images:
def handle_event("save", %{"product" => product_params}, socket) do
images_to_show = socket.assigns.images_to_show
images =
consume_uploaded_entries(socket, :images, fn meta, entry ->
filename = "#{entry.uuid}#{Path.extname(entry.client_name)}"
dest = Path.join(MercuryWeb.uploads_dir(), filename)
File.cp!(meta.path, dest)
{:ok, ~p"/uploads/#{filename}"}
end)
product_params = Map.put(product_params, "images", images_to_show ++ images)
save_product(socket, socket.assigns.action, product_params)
end
If we now remove an image and add a new one, the Product will correctly be saved
Nice, right?
Well, not completely. If you play around with the current code, you'll see that you can add more images than the maximum allowed by our requirements. In fact, you can go to the edit page and add 3 more images every time you save the form.
(Find the code for this part on the show-remove-add-images
branch of the git repo)
Ensure we don't exceed the allowed number of images
Now we have reached the part where the problem is. As it currently is, the live_file_input
has no way of knowing additional information other than the one you provide when you call the allow_update
function. It is also not possible to modify the configuration after initialization unless you destroy it and start over. live_file_input
doesn't know how many files to allow at any given moment because that depends on external information not available to it.
We can try some ways to address this issue.
Replacing the live_file_input configuration dynamically
We want the live_file_input
component to allow only enough files so that they, when added to the images that remain in the product after any removal by the user, don't exceed the maximum number of allowed files. For example, if we have a restriction of 3 files maximum, the product has already 2 images associated, and the user removes 1 in the edit page, then the live_file_input
component should allow 2 additional files to be uploaded.
This suggests we should be able to reconfigure the live_file_input
with the correct value for :max_entries
.
Something like this:
@impl true
def handle_event("remove", %{"image" => image}, socket) do
product = socket.assigns.product
removed_images = [image | socket.assigns.removed_images]
images_to_show = product.images -- removed_images
max_entries = @max_entries - length(images_to_show)
socket =
socket
|> assign(:removed_images, removed_images)
|> assign(:images_to_show, images_to_show)
|> allow_upload(:images,
accept: ~w(.png .jpg .jpeg),
max_entries: max_entries,
max_file_size: @max_file_size
)
{:noreply, socket}
end
If we now go to the edit form and remove an image we see that, if we start with 3 images associated to the product and we remove 1, the live_file_input
component allows us to upload only 1 file. If we remove 2, it will allow us to add 2 new files.
Trying to add 3 files, it shows the error message:
Removing one upload and trying to upload only two images, the error disappears.
It looks good!
Let's remove the last associated image and try to upload 3 completely new images:
Something went wrong.
Parameters: %{"image" => "/uploads/4c8cb89e-a9ef-4bb6-8672-960570e79fce.jpg"}
[error] GenServer #PID<0.2173.0> terminating
** (ArgumentError) cannot allow_upload on an existing upload with active entries.
Use cancel_upload and/or consume_upload to handle the active entries before allowing a new upload.
(phoenix_live_view 0.18.18) lib/phoenix_live_view/upload.ex:19: Phoenix.LiveView.Upload.allow_upload/3
There is a problem when we try to reconfigure the live_file_input
if there are active entries (those are the files ready to be uploaded and that we are seeing the preview on the screen).
As the message states, one way to avoid this error is to not have active entries when removing images. We can check for active entries and cancel them before trying to reconfigure the live_file_input
.
Phoenix has a method to do that. Let's use it:
@impl true
def handle_event("remove", %{"image" => image}, socket) do
product = socket.assigns.product
removed_images = [image | socket.assigns.removed_images]
images_to_show = product.images -- removed_images
max_entries = @max_entries - length(images_to_show)
socket =
socket
|> assign(:removed_images, removed_images)
|> assign(:images_to_show, images_to_show)
|> maybe_cancel_uploads()
|> allow_upload(:images,
accept: ~w(.png .jpg .jpeg),
max_entries: max_entries,
max_file_size: @max_file_size
)
{:noreply, socket}
end
defp maybe_cancel_uploads(socket) do
{socket, _} = Phoenix.LiveView.Upload.maybe_cancel_uploads(socket)
socket
end
This does the work, but not perfectly. When we cancel the active uploads in the remove
handler, they disappear from our preview section.
Before removing the remaining associated image
After removing it, the previews of the two active uploads are gone. :(
We can mitigate this a little by showing an alert to the user informing her that the pending uploads will be removed.
<a
phx-click="remove"
phx-target={@myself}
phx-value-image={image}
class="hover:text-slate-800"
data-confirm="Are you sure? The pending uploads will be removed and you need to select them again."
>
<.icon name="hero-trash" />
</a>
Now a warning is shown when you try to remove an existing image and there are active uploads.
Still, I don't quite like this approach.
(Find the code for this part on the replace-live-file-input-config
branch of the git repo)
Updating the max_entries option of live_file_input
Another way I tried was to change just the :max_entries
option of the allow_update()
call instead of completely replacing the configuration. Something like this:
@impl true
def handle_event("remove", %{"image" => image}, socket) do
product = socket.assigns.product
removed_images = [image | socket.assigns.removed_images]
images_to_show = product.images -- removed_images
max_entries = @max_entries - length(images_to_show)
socket =
socket
|> assign(:removed_images, removed_images)
|> assign(images_to_show: images_to_show)
|> maybe_update_upload_config(max_entries)
{:noreply, socket}
end
defp maybe_update_upload_config(socket, max_entries) do
images_config = Map.get(socket.assigns.uploads, :images)
new_uploads =
Map.put(socket.assigns.uploads, :images, %{images_config | max_entries: max_entries})
assign(socket, :uploads, new_uploads)
end
But Phoenix wasn't happy about it, telling me that the :uploads
is a reserved assign and I can't set it directly.
Parameters: %{"image" => "/uploads/c121a0d2-2724-4c03-8f7f-dc7c78c07ccc.jpg"}
[error] GenServer #PID<0.2770.0> terminating
** (ArgumentError) :uploads is a reserved assign by LiveView and it cannot be set directly
A dead-end path.
(Find the code for this part on the update-live-file-input-config
branch of the git repo)
Leveraging changeset validations
One way to completely work around the constraints of the live_file_input
config is to leave the configuration as it is and, instead, rely on the changeset
validations to apply the business logic checks.
Let's start with the Product's changeset. We can set a validation there to check the max amount of images allowed:
defmodule Mercury.Products.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :images, {:array, :string}
field :name, :string
timestamps()
end
@max_files 3
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :images])
|> validate_required([:name, :images])
|> validate_length(:images, max: @max_files, message: "Max number of images is #{@max_files}")
end
end
The validate_length
will check that we put at most @max_files
elements on the images
array.
Now we need to ensure the validate
and save
events put all the images the user wants to save in the images
attribute and the changeset will do the rest.
Let's first create a helper function that does the validation of the current data in the socket: the @images_to_show
and the entries
in the live_file_input
config:
defp validate_images(socket, product_params, images_to_show) do
images =
socket.assigns.uploads.images.entries
|> Enum.map(fn entry ->
filename = "#{entry.uuid}#{Path.extname(entry.client_name)}"
~p"/uploads/#{filename}"
end)
product_params = Map.put(product_params, "images", images_to_show ++ images)
changeset =
socket.assigns.product
|> Products.change_product(product_params)
|> Map.put(:action, :validate)
assign_form(socket, changeset)
end
This helper doesn't really consume the entries, but does iterate over them to generate the filenames so that it can add them to the images that remain associated to the product. That's the new set of images that the user is trying to associate to the product. Then delegates to the Products.change_product/2
(that then calls the Product.changeset/2
) to apply the validations. Finally it replaces the changeset in the socket so that the form in the browser shows the new errors if necessary.
With this in place the validate
handler is now:
@impl true
def handle_event("validate", %{"product" => product_params}, socket) do
images_to_show = socket.assigns.images_to_show
socket =
socket
|> validate_images(product_params, images_to_show)
{:noreply, socket}
end
The save
handler doesn't need changes as it already performs the same logic in addition to consuming the uploads.
There are other places where we need to do these checks, for example, when we remove an image from the product.
The remove
handler is now this:
@impl true
def handle_event("remove", %{"image" => image}, socket) do
product = socket.assigns.product
removed_images = [image | socket.assigns.removed_images]
images_to_show = product.images -- removed_images
socket =
socket
|> assign(:removed_images, removed_images)
|> assign(:images_to_show, images_to_show)
|> validate_images(%{}, images_to_show)
{:noreply, socket}
end
In this case, as we are not validating the full form, we can just pass an empty product_params
to the validate_images
function so that the changeset can validate if the images
are within the limits.
The cancel
event, triggered when we remove one active upload from the form, becomes:
def handle_event("cancel", %{"ref" => ref}, socket) do
images_to_show = socket.assigns.images_to_show
socket =
socket
|> cancel_upload(:images, ref)
|> validate_images(%{}, images_to_show)
{:noreply, socket}
end
Here again, we're not validating the full form, and we can just pass an empty product_params
to get the validations applied.
Finally, we show the error message in the template, if present:
<.label for="#images">Images</.label>
<.error :for={{msg, _} <- @form[:images].errors}><%= msg %></.error>
<div id="images">
With that in place, we can now try it.
We start with 3 images already in the Product:
If we try to upload an extra image, the validation shows the error:
We can now either remove one of the uploads or one of the images associated to the Product.
Removing one of the uploads:
Removing one of the associated images:
In both cases, the validations are reapplied and the error message is correctly handled.
I think this has better UX than the other options but still is not perfect. If you play around with the form you'll soon notice that the validations on other parts of the form are removed when we pass %{}
as the product_params to the validate_images
function. Sigh!
(Find the code for this part on the changeset-validations
branch of the git repo)
Last resort
One final approach is to completely abandon live_file_input
and changesets
and perform all the logic in the form_component.ex
module:
Use a boolean assign to toggle the showing/hiding of the max files error message in the template
Set/unset the toggle in each of the
remove
,cancel
,validate
, andsave
handlersCheck the assign before saving so that we don't save the form if the toggle is set.
This will work and won't mess with the form's internal errors or with the live_file_input
configuration.
But also it feels like is not in line with the "Elixir way" of doing things. But hey, it works, right?
Final considerations
This post is different than the ones I usually write, where I end with a happy feeling of accomplishment. This one feels like I personally couldn't find a way to make it work in an elegant way.
One thing is for sure, live_file_input
is amazing for the use case it was created. I am sure it will evolve to support or at least facilitate other use cases like the one discussed here.
Judging by the lightning speed the Phoenix core team and contributors gift us with amazing tools, I'm sure minds more brilliant than mine will find a better way to handle scenarios like this one.
And just to avoid any misunderstanding, I'm not complaining about live_file_input
, the bittersweet conclusions of this post fall totally on me and my limited experience with the way LiveView works.
What do you people think? Do you have another solution to this scenario that you want to share? I for sure want to learn it!
You can find the code for this article in this github repo
Photo by Leon Dewiwje on Unsplash
Top comments (0)