# 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:

```bash
# 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`:

```bash
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:

```bash
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*):

```elixir
<.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](https://hexdocs.pm/phoenix_live_view/uploads.html) 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`:

```elixir
  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*:

```elixir
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*:

```elixir
<.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*:

```elixir
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*:

```elixir
<.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:

```elixir
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:

```elixir
<.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*:

```elixir
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*:

```elixir
<.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*:

```elixir
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*:

```elixir

  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*:

```elixir
  @impl true
  def start(_type, _args) do
    MercuryWeb.uploads_dir() |> File.mkdir_p!()
    # ...
  end
```

Run migrations and start the server to try it:

```elixir
mix ecto.migrate
mix phx.server
```

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684582749811/69cbb826-dfaa-4b3f-aa01-13f0a3b54f8b.png align="center")

Create a new product:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684829871744/bb4dca4b-533f-4b23-8078-e84968a56697.png align="center")

Save it

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684582901607/aeec72e5-9930-42be-bdb4-892c3b218c5c.png align="center")

and go to the `show.html.heex`:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684582919552/35ea50ed-ee4d-48e7-9548-b49d37fef705.png align="center")

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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684830350586/b075a8c9-62f8-4088-9a2a-158f61cc2947.png align="center")

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.

```elixir
<.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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684665960140/93782f5b-c77d-4d03-a718-41aab17d978d.png align="center")

## 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:

```elixir
  @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:

```elixir
  @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:

```elixir
  @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:

```elixir
<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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684667397032/326b2f6d-4149-4cc2-a835-1fb1cb05f900.png align="center")

Let's modify the `save` event to correctly save the new set of images:

```elixir
  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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684668349680/b2e0a42b-3cbd-48d0-b677-1d9958cadb64.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684668368119/1b6e4d15-aac5-4f66-848d-ac4d3d45623d.png align="center")

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:

```elixir
  @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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684670531387/6ef08999-bf2c-4b98-b477-545abb11b6f5.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684670537595/42589e3a-bf87-4424-aea8-a4f03cf8d986.png align="center")

It looks good!

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684670958320/96c7429c-6de6-49d7-9748-dfd054284032.png align="center")

Something went wrong.

```bash
  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:

```elixir
  @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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684676121736/a762602c-c218-4c56-a999-730be9b9216c.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684681090341/de84f93b-f4d4-4b80-a057-3ba8993bdcc5.png align="center")

We can mitigate this a little by showing an alert to the user informing her that the pending uploads will be removed.

```elixir
<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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684681389701/550ee759-55e0-4c10-a9bb-e853eb206938.png align="center")

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:

```elixir
  @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.

```elixir
  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:

```elixir
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:

```elixir
  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:

```elixir
  @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:

```elixir
  @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:

```elixir
  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:

```elixir
<.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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684771133642/302a350c-813c-4cf2-8960-1a7c68b38fdf.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684845908794/eca3b591-5e23-4d80-a4cc-b1f3d083f13c.png align="center")

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

Removing one of the uploads:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684771422006/9ac76175-f63c-48af-9404-bb8bd72556f1.png align="center")

Removing one of the associated images:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1684845976438/adfb306f-d67d-4bdc-807f-5846615f8b74.png align="center")

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](https://github.com/miguelcoba/mercury)
