DEV Community

Cover image for Editing forms with live_file_input
Miguel Cobá
Miguel Cobá

Posted on • Originally published at blog.miguelcoba.com

Editing forms with live_file_input

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
Enter fullscreen mode Exit fullscreen mode

We can now generate a project. I'll name mine mercury:

mix phx.new mercury --install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}]}
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

lib/mercury/application.ex:

  @impl true
  def start(_type, _args) do
    MercuryWeb.uploads_dir() |> File.mkdir_p!()
    # ...
  end
Enter fullscreen mode Exit fullscreen mode

Run migrations and start the server to try it:

mix ecto.migrate
mix phx.server
Enter fullscreen mode Exit fullscreen mode

If you go now to http://localhost:4000/products

Image description

Create a new product:

Image description

Save it

Image description

and go to the show.html.heex:

Image description

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:

Image description

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:

  1. show the existing images if they exist

  2. be able to remove some of the existing images if we want to

  3. add new images to the product and save it

  4. 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>
Enter fullscreen mode Exit fullscreen mode

Now we can see the images in the edit form:

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

We can now remove images and if we reload without saving the changes, the original images are shown.

Image description

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
Enter fullscreen mode Exit fullscreen mode

If we now remove an image and add a new one, the Product will correctly be saved

Image description

Image description

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
Enter fullscreen mode Exit fullscreen mode

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:

Image description

Removing one upload and trying to upload only two images, the error disappears.

Image description

It looks good!

Let's remove the last associated image and try to upload 3 completely new images:

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Image description

After removing it, the previews of the two active uploads are gone. :(

Image description

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>
Enter fullscreen mode Exit fullscreen mode

Now a warning is shown when you try to remove an existing image and there are active uploads.

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

With that in place, we can now try it.

We start with 3 images already in the Product:

Image description

If we try to upload an extra image, the validation shows the error:

Image description

We can now either remove one of the uploads or one of the images associated to the Product.

Removing one of the uploads:

Image description

Removing one of the associated images:

Image description

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, and save handlers

  • Check 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)