LiveView: Nested inputs not working with certain handle_events

I have a phoenix app consisting of Posts & comments. A post can have many comments. I’m trying to put together a nested input form but failing with one of the handle events. Here is my code:

defmodule LiveBlogWeb.BlogLive.New do
  alias LiveBlog.Blog.Comment
  use LiveBlogWeb, :live_view

  alias LiveBlog.Blog
  alias LiveBlog.Blog.Post
  alias LiveBlog.Blog.Comment

  @impl true
  def mount(_params, _session, socket) do
    changeset = Blog.change_post(%Post{comments: [%Comment{}]})
    # changeset = Blog.change_post(%Post{comments: [%Comment{}]})

    form =
      %Post{}
      |> Post.changeset(%{})
      |> to_form(as: "post")

    {:ok, socket |> assign(form: form, changeset: changeset)}
  end

  @impl true
  def handle_event("validate", %{"post" => post_params}, socket) do
    post = Ecto.Changeset.apply_changes(socket.assigns.changeset)

    changeset =
      Blog.change_post(post, post_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("save", %{"post" => post_params}, socket) do
    case Blog.create_post(post_params) do
      {:ok, _post} ->
        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully.")
         |> push_navigate(to: "/blog")}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

  def handle_event("remove-comment", %{"index" => index}, socket) do
    index = String.to_integer(index)
    params = socket.assigns.changeset.params || %{}
    post = Ecto.Changeset.apply_changes(socket.assigns.changeset)
    # data = socket.assigns.changeset.data

    changeset =
      post
      |> Blog.change_post(params)

    # comments = get_comments_from_changeset(changeset)
    comments = Ecto.Changeset.get_field(changeset, :comments, [])
    updated_comments = List.delete_at(comments, index)

    updated_changeset =
      post
      |> Blog.change_post(params)
      |> Ecto.Changeset.put_assoc(:comments, updated_comments)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: updated_changeset)}
  end

  def handle_event("add-comment", _params, socket) do
    # Handle adding a comment if needed
    params = socket.assigns.changeset.params || %{}
    # data = socket.assigns.changeset.data
    post = Ecto.Changeset.apply_changes(socket.assigns.changeset)
    changeset = Blog.change_post(post, params)

    # changeset =
    # socket.assigns.changeset.data
    # |> Blog.change_post(params)

    current_comments = Ecto.Changeset.get_assoc(changeset, :comments)
    updated_comments = current_comments ++ [%Comment{}]

    IO.inspect(updated_comments)

    updated_changeset =
      post
      |> Blog.change_post(params)
      |> Ecto.Changeset.put_assoc(:comments, updated_comments)
      |> Map.put(:action, :validate)

    # changeset =
    #   %Post{}
    #   |> Post.changeset(%{})
    #  |> Ecto.Changeset.put_assoc(:comments, updated)

    {:noreply, assign(socket, changeset: updated_changeset)}
  end

  defp get_comments_from_changeset(changeset) do
    case Ecto.Changeset.get_field(changeset, :comments, []) do
      nil -> [%Comment{}]
      comments -> comments
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="max-w-4xl mx-auto py-8 px-4">
      <h1>New Post</h1>
      <.form :let={f} for={@changeset} id="post-form" phx-submit="save">
        <.input field={f[:title]} type="text" label="Title" required />
        <.input field={f[:summary]} type="text" label="Summary" required />
        <.input field={f[:body]} type="textarea" label="Body" required />
        <div id="comments">
          <.inputs_for :let={comment} field={f[:comments]}>
            <.input
              field={comment[:body]}
              type="textarea"
              label="Comment"
              placeholder="Start a comment"
            />
            <.button type="button" phx-click="remove-comment" phx-value-index={comment.index}>
              Remove
            </.button>
          </.inputs_for>
        </div>
        <.button phx-click="add-comment" type="button">
          Add Comment
        </.button>
        <div class="flex justify-end">
          <.button phx-disable-with="Saving...">Save</.button>
        </div>
      </.form>
    </div>
    """
  end
end

add-comment event adds a new comment input form, but when I try and delete I get the following error:

warning] found duplicate primary keys for association/embed `:comments` in `LiveBlog.Blog.Post`. In case of duplicate IDs, only the last entry with the same ID will be kept. Make sure that all entries in `:comments` have an ID and the IDs are unique between them

Also filling in the post form resets the comments section. Any idea what i’m doing wrong?

Check out Phoenix.Component — Phoenix LiveView v1.0.9, it was created especially for thes3ckind of situations.

thanks! I’ll check it out, I was already using the inputs_for component, I’ve now modified by Post Ecto Schema to match the docs but I’m using a has_many association not embeds_many. My final Post Schema looks like this:

defmodule LiveBlog.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :summary, :string
    field :body, :string

    has_many :comments, LiveBlog.Blog.Comment,
      on_delete: :delete_all,
      preload_order: [desc: :id],
      on_replace: :delete

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :summary, :body])
    |> validate_required([:title, :summary, :body])
    |> cast_assoc(:comments,
      with: &LiveBlog.Blog.Comment.changeset/2,
      drop_param: :comments_drop,
      sort_param: :comments_sort
    )
  end
end

I’ll go through the live view handle_event calls and work it slowly. I’ll update once I make some progress…

It doesn’t matter if it’s a has_many or embedded, it works the same.
I implemented it a couple of days ago, works great.

Updated my live view code and that seems to have solved the duplicate primary keys error by adding a UUID for each new nested entry:

defmodule LiveBlogWeb.BlogLive.New do
  alias LiveBlog.Blog.Comment
  use LiveBlogWeb, :live_view

  alias LiveBlog.Blog
  alias LiveBlog.Blog.Post
  alias LiveBlog.Blog.Comment

  @impl true
  def mount(_params, _session, socket) do
    changeset = Blog.change_post(%Post{comments: [%Comment{id: Ecto.UUID.generate()}]})
    # changeset = Blog.change_post(%Post{comments: [%Comment{}]})

    _form =
      %Post{}
      |> Post.changeset(%{})
      |> to_form(as: "post")

    {:ok, socket |> assign(changeset: changeset)}
  end

  @impl true
  def handle_event("validate", %{"post" => post_params}, socket) do
    post = Ecto.Changeset.apply_changes(socket.assigns.changeset)

    changeset =
      Blog.change_post(post, post_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("save", %{"post" => post_params}, socket) do
    case Blog.create_post(post_params) do
      {:ok, _post} ->
        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully.")
         |> push_navigate(to: "/post")}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

  def handle_event("remove-comment", %{"id" => nil}, socket) do
    # do nothing, or log a warning
    {:noreply, socket}
  end

  def handle_event("remove-comment", %{"id" => id}, socket) do
    # index = String.to_integer(idx)
    params = socket.assigns.changeset.params || %{}
    post = Ecto.Changeset.apply_changes(socket.assigns.changeset)
    # data = socket.assigns.changeset.data

    changeset =
      post
      |> Blog.change_post(params)

    # comments = get_comments_from_changeset(changeset)
    comments = Ecto.Changeset.get_field(changeset, :comments, [])
    # updated_comments = List.delete_at(comments, index)

    filtered = Enum.reject(comments, fn c -> to_string(c.id) == id end)

    updated_changeset =
      post
      |> Blog.change_post(params)
      |> Ecto.Changeset.put_assoc(:comments, filtered)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: updated_changeset)}
  end

  def handle_event("add-comment", _params, socket) do
    # Handle adding a comment if needed
    params = socket.assigns.changeset.params || %{}
    post = Ecto.Changeset.apply_changes(socket.assigns.changeset)

    changeset =
      post
      |> Blog.change_post(params)

    # changeset =
    # socket.assigns.changeset.data
    # |> Blog.change_post(params)

    current_comments = Ecto.Changeset.get_field(changeset, :comments, [])
    new_comments = current_comments ++ [%Comment{id: Ecto.UUID.generate()}]

    IO.inspect(new_comments)

    updated_changeset =
      post
      |> Blog.change_post(params)
      |> Ecto.Changeset.put_assoc(:comments, new_comments)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: updated_changeset)}
  end

  defp append_comment_param(changeset, extra \\ %{"id" => Ecto.UUID.generate(), "body" => ""}) do
    params = changeset.params || %{}
    comments = Map.get(params, "comments", [])
    new_params = Map.put(params, "comments", comments ++ [extra])
    {Ecto.Changeset.apply_changes(changeset), new_params}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="max-w-4xl mx-auto py-8 px-4">
      <h1>New Post</h1>
      <.form :let={f} for={@changeset} id="post-form" phx-submit="save" phx-changed="validate">
        <.input field={f[:title]} type="text" label="Title" required />
        <.input field={f[:summary]} type="text" label="Summary" required />
        <.input field={f[:body]} type="textarea" label="Body" required />
        <div id="comments">
          <.inputs_for :let={comment} field={f[:comments]}>
            <input type="hidden" name="post[comments_sort][]" value={comment.index} />
            <.input
              field={comment[:body]}
              type="textarea"
              label="Comment"
              placeholder="Start a comment"
              required
            />
            <%= if length(Ecto.Changeset.get_field(@changeset, :comments, [])) > 1 do %>
              <button
                type="button"
                name="post[comments_drop][]"
                phx-value-id={comment.data.id}
                phx-click="remove-comment"
              >
                <.icon name="hero-x-mark" class="w-6 h-6 relative top-2" />
              </button>
            <% end %>
          </.inputs_for>
        </div>
        <input type="hidden" name="post[comments_sort][]" />
        <button
          type="button"
          phx-click="add-comment"
          value="new"
          class="px-4 py-2
        rounded-md
        font-medium bg-blue-700 text-white shadow-lg"
        >
          add more
        </button>
        <div class="flex justify-end">
          <.button phx-disable-with="Saving...">Save</.button>
        </div>
      </.form>
    </div>
    """
  end
end

But now there’s a weird bug where when I click on save it adds a new nested input instead of persisting the data. I’ve looked at my save handle_event and nothing seems out of place. The save event works fine if I’m not appending new comments, just adding one comment. Any idea what is going on?

I think you are missing or misinterpreted some of the information in the link I gave you.
You don’t need handlers for adding/removing comments, by using the combination of sort_param and drop_param Ecto does this for you.

Let me show you how I used it just a couple of days ago.
I have a Company that has 1 or more Identifiers and I needed to be able to manage them.

This is the company schema:

defmodule Schema.Company do
  use Ecto.Schema
  @moduledoc false

  @type t :: %__MODULE__{
          id: integer(),
          identifier: String.t(),
          password: String.t(),
          password_hash: String.t(),
          name: String.t(),
          email: String.t(),
          hook: map(),
          admin: boolean()
        }

  schema "companies" do
    field :identifier, :string
    field :password, :string, virtual: true
    field :password_hash, :string
    field :name, :string
    field :email, :string
    field :hook, :map
    field :admin, :boolean

    has_many :identifiers, Schema.PeppolIdentifier, on_replace: :delete
    has_many :documents, Schema.DocumentStore
  end
end

and

defmodule Schema.PeppolIdentifier do
  @moduledoc false
  use Ecto.Schema

  @type t :: %__MODULE__{
          company_id: integer(),
          identifier: String.t(),
  }
  @primary_key false
  schema "peppol_identifiers" do
    belongs_to :company, Schema.Company
    field :identifier, :string, primary_key: true
  end
end

These are the relevant functions in the Companies module;

  @spec create(map()) :: {:ok, Schema.Company.t()} | {:error, Ecto.Changeset.t()}
  def create(attrs) do
    %Schema.Company{}
    |> changeset(attrs)
    |> Repo.insert()
  end

  @spec update(Schema.Company.t(), map()) ::
          {:ok, Schema.Company.t()} | {:error, Ecto.Changeset.t()}
  def update(company, attrs) do
    company
    |> changeset(attrs)
    |> Repo.update()
  end

    @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t()
  def changeset(company, attrs) do
    company
    |> cast(attrs, [:identifier, :password, :name, :email, :hook, :admin])
    |> update_change(:email, &String.downcase/1)
    |> validate_required([:identifier, :name, :email])
    |> put_password_hash()
    |> cast_assoc(:identifiers,
      with: &identifier_changeset/2,
      sort_param: :identifiers_sort,
      drop_param: :identifiers_drop
    )
  end

  @spec identifier_changeset(Schema.PeppolIdentifier.t(), map()) :: Ecto.Changeset.t()
  def identifier_changeset(identifier, attrs) do
    identifier
    |> cast(attrs, [:identifier])
    |> validate_required(:identifier)
  end

  @spec fetch(integer()) :: {:ok, Schema.Company.t()} | {:error, :not_found}
  def fetch(id, preload \\ []) do
    case Repo.get(Schema.Company, id) do
      nil -> {:error, :not_found}
      company -> {:ok, company |> Repo.preload(preload)}
    end
  end

And this is the whole live view page:

defmodule PeppolWeb.CompanyLive.Edit do
  alias Peppol.Companies
  use PeppolWeb, :live_view

  def handle_params(%{"id" => "new"}, _uri, socket) do
    company = %Schema.Company{}

    form =
      Companies.changeset(company, %{})
      |> to_form()

    socket
    |> assign(:form, form)
    |> assign(:company, company)
    |> noreply()
  end

  def handle_params(%{"id" => id}, _uri, socket) do
    with {:ok, company} <- Companies.fetch(id, :identifiers) do
      form =
        Companies.changeset(company, %{})
        |> to_form()

      socket
      |> assign(:form, form)
      |> assign(:company, company)
    else
      {:error, :not_found} ->
        socket
        |> put_flash(:error, "Company not found")
        |> push_navigate(~p"/")
    end
    |> noreply()
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-xl font-semibold">{if @company.id, do: "Edit", else: "Add"} Company</h1>

    <.form
      for={@form}
      id="new-company-form"
      phx-change="validate"
      phx-submit={if @company.id, do: "update", else: "create"}
    >
      <.input type="text" field={@form[:name]} label="Name" />
      <.input type="text" field={@form[:identifier]} label="Company number (login)" />
      <.input type="email" field={@form[:email]} label="E-mail address" />
      <.input type="text" field={@form[:hook]} label="Web-hook" />
      <.input type="password" field={@form[:password]} label="Password" />
      <div class="mt-2">
        <.button
          type="button"
          name="company[identifiers_sort][]"
          value="new"
          phx-click={JS.dispatch("change")}
        >
          Add peppol identifier
        </.button>
      </div>
      <.inputs_for :let={identifier} field={@form[:identifiers]}>
        <div class="flex items-center">
          <.input type="text" field={identifier[:identifier]} placeholder="Peppol identifier" />
          <button
            type="button"
            name="company[identifiers_drop][]"
            value={identifier.index}
            phx-click={JS.dispatch("change")}
          >
            <.icon name="hero-x-mark" class="w-6 h-6 relative top-1" />
          </button>
        </div>
      </.inputs_for>
      <input type="hidden" name="company[identifiers_drop][]" />
      <div class="my-2">
        <.input type="checkbox" field={@form[:admin]} label="Administrative access" />
      </div>
      <.button type="submit">{if @company.id, do: "Update", else: "Add"}</.button>
      <.link navigate={~p"/companies"} class="ml-2">
        <.button type="button">Close</.button>
      </.link>
    </.form>
    """
  end

  def handle_event("validate", %{"company" => attrs}, socket) do
    form =
      Companies.changeset(%Schema.Company{}, attrs)
      |> to_form()

    socket
    |> assign(:form, form)
    |> noreply()
  end

  def handle_event("create", %{"company" => attrs}, socket) do
    with {:ok, company} <- Companies.create(attrs) do
      socket
      |> put_flash(:success, "Company created")
      |> push_navigate(to: ~p"/companies/#{company.id}")
    else
      {:error, changeset} ->
        form = to_form(changeset)

        socket
        |> assign(:form, form)
        |> put_flash(:error, "Failed to create company")
    end
    |> noreply()
  end

  def handle_event("update", %{"company" => attrs}, socket) do
    with {:ok, _company} <- Companies.update(socket.assigns.company, attrs) do
      socket
      |> put_flash(:success, "Company updated")
      |> push_navigate(to: ~p"/companies")
    else
      {:error, changeset} ->
        form = to_form(changeset)

        socket
        |> assign(:form, form)
        |> put_flash(:error, "Failed to update company")
    end
    |> noreply()
  end
end

I hope this helps.

Can You show the Comment schema? I guess You are not using autogenerated id, of type binary_id

Thanks for this, started everything from scratch with a new app and schema, Department ->[has_many] Employees. The nested inputs work in terms of adding and removing employees, but weirdly clicking on the save does nothing, I can see the params in the terminal but nothing is being persisted. Here is my live view code:

defmodule AcmeWeb.AcmeLive.New do
  use AcmeWeb, :live_view
  alias Acme.Catalog
  alias Acme.Catalog.Department

  def mount(_params, _session, socket) do
    # form =
    # %Department{}
    #  |> Catalog.change_department(%{employees: [%Employee{}]})
    #  |> Map.put(:action, :insert)

    # {:ok, assign(socket, :changeset, form)}
    {:ok, assign(socket, :changeset, Catalog.change_department(%Department{}))}
  end

  def handle_event("validate", %{"department" => department_params}, socket) do
    changeset =
      %Department{}
      |> Catalog.change_department(department_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :changeset, changeset)}
  end

  def handle_event("save", %{"department" => department_params}, socket) do
    IO.inspect(department_params, label: "Department Params")

    case Catalog.create_department(department_params) do
      {:ok, _department} ->
        {:noreply,
         socket
         |> put_flash(:info, "Department created successfully.")
         |> push_navigate(to: ~p"/acme/departments")}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, :changeset, changeset)}
    end
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-4xl mx-auto py-8 px-4">
      <.form :let={dept} for={@changeset} id="department-form" phx-change="validate" phx-submit="save">
        <div class="mb-3">
          <.input field={dept[:title]} type="text" label="Department Title" />
        </div>
        <%= if length(Ecto.Changeset.get_field(@changeset, :employees, [])) > 0 do %>
          <h3 class="mt-6 text-left text-2xl font-extrabold text-gray-900">
            Employees
          </h3>
        <% end %>
        <.inputs_for :let={employee} field={dept[:employees]}>
          <input type="hidden" name="department[employees_sort][]" value={employee.index} />
          <div class="mb-3">
            <.input field={employee[:first_name]} type="text" label="First Name" />
            <.input field={employee[:middle_name]} type="text" label="Middle Name" />
            <.input field={employee[:last_name]} type="text" label="Last Name" />
            <.input field={employee[:work_email]} type="email" label="Work Email" />
            <button
              type="button"
              name="department[employees_drop][]"
              value={employee.index}
              phx-click={JS.dispatch("change")}
              class="px-4 py-2 rounded-md font-medium bg-red-700 text-white shadow-lg"
            >
              Remove
            </button>
          </div>
        </.inputs_for>
        <input type="hidden" name="department[employees_drop][]" />
        <button
          type="button"
          name="department[employees_sort][]"
          value="new"
          phx-click={JS.dispatch("change")}
          class="px-4 py-2 rounded-md font-medium bg-blue-700 text-white shadow-lg"
        >
          Add Employee
        </button>
        <div class="flex justify-end">
          <.button phx-disable-with="Saving..." type="submit" value="save">Save</.button>
        </div>
        <div class="flex justify-start">
          <.link navigate={~p"/acme/departments"} class="ml-2">
            <.button type="button">Close</.button>
          </.link>
        </div>
      </.form>
    </div>
    """
  end
end

What comes back from your Catalog.create_company function?

I don’t have a Catalog.create_company function, I do have a Catalog.create_department function and the params look like this if I add a Department and one employee.

Parameters: %{"department" => 
%{"employees" => 
%{"0" => 
%{"_persistent_id" => "0", 
  "first_name" => "Jane", 
  "last_name" => "Doe", 
  "middle_name" => "", 
  "work_email" => 
  "janedoe@gmail.com"
}}, 
  "employees_drop" => [""], 
  "employees_sort" => ["0"], 
"title" => "Sales"
}}

Sorry, my bad, department it was. Still I would like to see the result of that function with your input, add a |> dbg() to it and post the result.

Turns out there was a field that was failing validation but somehow I couldn’t see it…all fixed..works very well now..wondering if nested inputs_for will work the same way

1 Like