LiveView individual input component with embeds_many schema instead of .inputs_for

I have a LiveView stateful component used to upload files. It uses an Ecto schema to structure things (not backed by a DB). There is an array of files in the changeset and I would like to be able to change the name of a file and add an optional message after it has been uploaded but before the form is saved.

I read the post at One-to-Many LiveView Form | Benjamin Milde by @LostKobrakai which was very useful but what I want is a little bit different.

If I use <.inputs_for :let={file} field={@form[:files]}> it works as expected but I would like to address the individual files like if I hypothetically had written <.input_for :let={file} field={@form[:files][index]}> where index is the number of the file in the array or key in the map. In other words, I’d like to move the file1 component (two inputs and a button) inside the for loop generating the article elements instead of putting it outside of it using <.inputs_for>. The idea is to tie the controls to each individual uploaded file and not put them all below all the uploads.

The problem of course is that I cannot index the array or map to get the individual files and if I try to do so by writing field={@form[:files][0]} or field={@form[:files]["0"]} I get an access error: Phoenix.HTML.FormField does not implement the Access behaviour. I don’t even know how to write the .input component using its name attribute.

How could I put <.file1 file={file} myself={@myself}/> inside the loop ?

defmodule MaintenanceWeb.MenuLive.Actions.Autre do
  use MaintenanceWeb, :live_component
  use Ecto.Schema
  import Ecto.Changeset

  schema "autre" do
    field(:datetime, :naive_datetime)
    field(:message, :string)
    embeds_many :files, MyFile do
      field(:ref, :string)
      field(:name, :string)
      field(:message, :string)
    end
  end

  def render(assigns) do
    ~H"""
    <div>
      <p>Inside H Component MaintenanceWeb.MenuLive.Actions.Autre</p>
      <.simple_form id="form" for={@form} phx-change="validate" phx-submit="save" phx-target={@myself}>
        <.input type="datetime-local" field={@form[:datetime]}/>
        <.input type="textarea" field={@form[:message]}/>
        <.live_file_input upload={@uploads.files} multiple/>
        <section id="uploaded-files">
          <h2>Uploaded Files (<%= length(@uploaded_files) %>)</h2>

            <%= for {path, entry} <- @uploaded_files do %>
              <article class="uploaded-files-entry">
                <div class="preview">
                <%= case mime2type!(entry.client_type) do %>
                  <% "image" -> %>
                    <img class="preview" src={path}/>
                  <% "audio" -> %>
                    <audio class="preview" controls preload="metadata" src={path}>Your browser does not support the <code>audio</code> element.</audio>
                  <% "video" -> %>
                    <video class="preview" controls muted preload="metadata" src={path}>Your browser does not support the <code>video</code> element.</video>
                  <% "_" -> %>
                    <p>N/A</p>
                <% end %>
                </div>
              </article>
            <% end %>

            <.inputs_for :let={file} field={@form[:files]}>
              <.file1 file={file} myself={@myself}/>
            </.inputs_for>
        </section>
        <:actions>
          <.button phx-disable-with="Saving...">Save</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  def file1(assigns) do
    ~H"""
    <.input type="text" class="uploaded-file-name" field={@file[:name]}/>
    <.button type="button" id="remove-upload" phx-target={@myself} phx-click="remove-upload" phx-value-index={@file.index} aria-label="remove">❌</.button>
    <.input type="textarea" field={@file[:message]} placeholder="Description optionnelle du fichier"/>
    """
  end
2 Likes

I’m not sure if I’m following correctly…are you asking how you can split the inputs out so you have the files listed in one place of your form, and the name fields elsewhere?

If that’s the question, then one thing you can do is use multiple inputs_for. In one just have the files, in the other just have the name input.

One other note: you’re using this to remove the files:

<.button type="button" id="remove-upload" phx-target={@myself} phx-click="remove-upload" phx-value-index={@file.index} aria-label="remove">❌</.button>

As of a recent version of Ecto, you can instead use the drop param to simplify this. No need to write your own custom handle_event for removal. Additionally, when iterating through a collection with inputs_for, the form will provide an index field for you to use:

<input
  type="checkbox"
  name="autre[file_drop][]"
  value={autre_file_form.index}
  class="hidden"
/>
2 Likes

Instead of having the input elements decoupled from the article elements, I’d like to have them under the same article parent element.

Instead of this:

<section id="uploaded-files">
    <%= for {path, entry} <- @uploaded_files do %>
      <article class="uploaded-files-entry">
        <div class="preview">
          <%= case mime2type!(entry.client_type) do %>
            <% "image" -> %>
              <img class="preview" src={path}/>
            <% "audio" -> %>
              <audio class="preview" controls preload="metadata" src={path}>Your browser does not support the <code>audio</code> element.</audio>
            <% "video" -> %>
              <video class="preview" controls muted preload="metadata" src={path}>Your browser does not support the <code>video</code> element.</video>
            <% "_" -> %>
              <p>N/A</p>
          <% end %>
        </div>
      </article>
    <% end %>
    <.inputs_for :let={file} field={@form[:files]}>
      <.file1 file={file} myself={@myself}/>
    </.inputs_for>
</section>

I’d like to have this (note that I used a non-existing syntax):

<section id="uploaded-files">
    <%= for {path, entry} <- @uploaded_files do %>
      <article class="uploaded-files-entry">
        <div class="preview">
          <%= case mime2type!(entry.client_type) do %>
            <% "image" -> %>
              <img class="preview" src={path}/>
            <% "audio" -> %>
              <audio class="preview" controls preload="metadata" src={path}>Your browser does not support the <code>audio</code> element.</audio>
            <% "video" -> %>
              <video class="preview" controls muted preload="metadata" src={path}>Your browser does not support the <code>video</code> element.</video>
            <% "_" -> %>
              <p>N/A</p>
          <% end %>
        </div>
        <.input_for :let={file} field={@form[:files][index]}>
          <.file1 file={file} myself={@myself}/>
        </.input_for>
      </article>
    <% end %>
</section>

Hmm, not sure what the best practice would be, but you could try using something along the lines of :if={file.ref == entry.ref} to only show the related file.

<section id="uploaded-files">
    <%= for {path, entry} <- @uploaded_files do %>
      <article class="uploaded-files-entry">
        <div class="preview">
         ...
        </div>
        <.input_for :let={file} field={@form[:files]}>
          <.file1 :if={file.ref == entry.ref} file={file} myself={@myself}/>
        </.input_for>
      </article>
    <% end %>
</section>

Alternatively, you could decorate each element of the @uploaded_files list assign to include the form data when you consume_uploaded_entries and append to @uploaded_files such that you can do something like for {path, entry, file_data} <- @uploaded_files.

Could you share the data structure of @uploaded_files and show how its currently set?

1 Like

@uploaded_files just contains the list of tuples {path, entry} received by consume_uploaded_entry but with the new path to the copied file. That is the information I get from the uploads. On the other hand, I also have an Ecto.Changeset which embeds the name and message of all the files given by the form on the web page.

I think I will just structure the HTML code more or less like below. And use a little bit of CSS to have the uploaded files previews side by side with the HTML inputs to change their name and add an optional message (or comment). I have added a hidden input to be able to link each file in @form[:files] to its matching entry in @uploaded_files.

<section id="uploaded-files">
    <div id="previews">
      <%= for {path, entry} <- @uploaded_files do %>
        <article class="uploaded-files-entry">
          <div class="preview">
           ...
          </div>
        </article>
      <% end %>
    </div>
    <div id="inputs">
      <.inputs_for :let={file} field={@form[:files]}>
        <.file1 file={file} myself={@myself}/>
      </.inputs_for>
    </div>
</section>

  def file1(assigns) do
    ~H"""
    <.input type="hidden" field={@file[:ref]}/>
    <.input type="text" class="uploaded-file-name" field={@file[:name]}/>
    <.button type="button" id="remove-upload" phx-target={@myself} phx-click="remove-upload" phx-value-index={@file.index} aria-label="remove">❌</.button>
    <.input type="textarea" field={@file[:message]} placeholder="Description optionnelle du fichier"/>
    """
  end

EDIt: Note that the id on button should be a class instead.