Removing multiple items in a LiveView containing nested form (inputs_for) on a select

Hi everyone !

I’m currently trying to build a relation inside a Phoenix app.

Here is a sample app I made, with simpler schema just for you to get the idea.

  schema "books" do
    field :title, :string

    has_many :book_publisher, Entity.BookPublisher, on_replace: :delete

    timestamps()
  end
  schema "publishers" do
    field :name, :string

    has_many :book_publisher, Entity.BookPublisher, on_replace: :delete

    timestamps()
  end
  schema "books_publishers" do
    field :date, :date
    belongs_to :book, Entity.Book, foreign_key: :book_id
    belongs_to :publisher, Entity.Publisher, foreign_key: :publisher_id

    timestamps()
  end

There is books, there is publishers, and a book can have multiple editions, identified by a date and a publisher.

When editing a view, I have this template, mounted :

<%= f = form_for @changeset, "#", [phx_change: :validate, phx_submit: :save] %>
  <%= if @changeset.action do %>
    <%= if !@changeset.valid? do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
    <% end %>
  <% end %>

  <%= label f, :title %>
  <%= text_input f, :title, phx_debounce: "blur" %>
  <%= error_tag f, :title %>

  <label>Editions</label>

  <div>
    <%= link "Add New Edition", to: "#", "phx-click": "add-edition" %>
  </div>

  <%= for {fb, index} <- Enum.with_index(inputs_for f, :book_publisher) do %>
    <div class="editions">
    <%= select fb, :publisher_id, Enum.map(@publishers, fn e -> {e.name, e.id} end) %>
    <%= date_input fb, :date %>
    <%= link "Remove", to: "#", "phx-click": "remove-edition", "phx-value-index": index %>
    </div>
  <% end %>


  <div style="padding-top: 1rem;">
    <%= submit "Save", phx_disable_with: "Saving..." %>
  </div>
</form>

Corresponding handlers are :

  use LveWeb, :live_view

  alias LveWeb.BookView
  alias Lve.Entity
  alias Lve.Repo

  def render(assigns) do
    BookView.render("edit.html", assigns)
  end

  def mount(params, _sesion, socket) do
    id = params["id"]
    publishers = Entity.list_publishers()
    book = Entity.get_book!(id) |> Repo.preload(:book_publisher)

    cs =
      id
      |> Entity.get_full_book!()
      |> Entity.change_book()

    {:ok, assign(socket, changeset: cs, book: book, publishers: publishers)}
  end

  def handle_event("validate", %{"book" => params}, socket) do
    cs =
      %Entity.Book{}
      |> Entity.change_book(params)
      |> Map.put(:action, :insert)

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

  def handle_event("save", %{"book" => params}, socket) do
    book = Entity.get_book!(socket.assigns.book.id) |> Repo.preload(:book_publisher)

    case Entity.update_book(book, params) do
      {:ok, book} ->
        {:noreply,
         socket
         |> put_flash(:info, "Book updated")
         |> redirect(to: Routes.live_path(socket, LveWeb.BookLive.Show, book.id))}

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

  def handle_event("add-edition", _params, socket) do
    currents =
      Map.get(
        socket.assigns.changeset.changes,
        :book_publisher,
        socket.assigns.book.book_publisher
      )

    [first_publisher | _] = socket.assigns.publishers

    next = %Entity.BookPublisher{
      publisher_id: first_publisher.id,
      book_id: socket.assigns.book.id
    }

    editions = [next | currents]

    cs = socket.assigns.changeset |> Ecto.Changeset.put_assoc(:book_publisher, editions)

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

  def handle_event("remove-edition", %{"index" => index}, socket) do
    currents =
      Map.get(
        socket.assigns.changeset.changes,
        :book_publisher,
        socket.assigns.book.book_publisher
      )

    {i, _} = Integer.parse(index)

    editions = List.delete_at(currents, i)

    cs = socket.assigns.changeset |> Ecto.Changeset.put_assoc(:book_publisher, editions)

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

When interacting with the DB, I use this changeset :

  @doc false
  def changeset(book, attrs) do
    book
    |> Repo.preload(:book_publisher)
    |> cast(attrs, [:title])
    |> validate_required([:title])
    |> unique_constraint(:title)
    |> put_assoc(:book_publisher, convert_assoc(attrs["book_publisher"]))
  end

  def convert_assoc(nil) do
  end

  def convert_assoc([]) do
  end

  def convert_assoc(list) do
    Enum.map(list, fn {_, %{"date" => _, "publisher_id" => id}} ->
      {pid, _} = Integer.parse(id)
      %{date: nil, publisher_id: pid}
    end)
  end

Adding data works great, removing data causes an error :

cannot replace related %Lve.Entity.BookPublisher{[...]} because it already exists and it is not currently associated with the given struct. Ecto forbids casting existing records through the association field for security reasons. Instead, set the foreign key value accordingly

It looks like Ecto is not happy with a state mismatch between data from DB and data from my changeset that contains “data to remove” from the association

After days of searching, I don’t know what to do. Am I doing this right ?

Thanks for your help.

NB : Code sandbox is available here https://github.com/papey/lve/tree/features/dynamic-editions-form

Edit : after some time, i think cast_assoc is the good tool here, working on that.

1 Like