I’m creating a complex form using LiveView. I’m using an Ecto.Changeset
to render the form, so when the user changes something I update the changeset to re-render the form. I have most of the functionality working without problem, except for deleting a grandchild association.
I created a simplified example of my real code to show my case. I’m editing a Foo
schema which has many Bar
in :bars
field. At the same time, a Bar
struct has many Baz
in :bazs
field. The code is the following:
defmodule Project.Foo do
use Ecto.Schema
schema "foos" do
field :foo, :string, default: "foo"
has_many :bars, Project.Bar
end
def changeset(%__MODULE__{} = schema, params) do
schema
|> Ecto.Changeset.cast(params, [:foo])
|> Ecto.Changeset.cast_assoc(:bars)
end
end
defmodule Project.Bar do
use Ecto.Schema
schema "bars" do
field :bar, :string, default: "bar"
has_many :bazs, Project.Baz
end
def changeset(%__MODULE__{} = schema, params) do
schema
|> Ecto.Changeset.cast(params, [:bar])
|> Ecto.Changeset.cast_assoc(:bazs)
end
end
defmodule Project.Baz do
use Ecto.Schema
schema "bazs" do
field :baz, :string, default: "baz"
end
def changeset(%__MODULE__{} = schema, params) do
schema
|> Ecto.cast(params, [:baz])
end
end
defmodule Project.FooLive do
use Phoenix.LiveView
use Phoenix.HTML
def mount(%{}, socket) do
changeset = %Project.Foo{bars: [%Project.Bar{bazs: [%Project.Baz{}]}]}
|> Ecto.Changeset.change()
{:ok, assign(socket, changeset: changeset)}
end
def render(assigns) do
~L"""
<%= f = form_for assigns.changeset, "#", [] %>
<%= label f, :foo %>
<%= text_input f, :foo %>
<%= inputs_for f, :bars, fn bar_f -> %>
<%= label bar_f, :bar %>
<%= text_input bar_f, :bar %>
<%= inputs_for bar_f, :bazs, fn baz_f -> %>
<%= label baz_f, :baz %>
<%= text_input baz_f, :baz %>
<button phx-click="delete_bar" phx-value="<%= "#{bar_f.index}-#{baz_f.index}" %>">Delete Bar</button>
<% end %>
<% end %>
</form>
"""
end
def handle_event("delete_bar", indexes, socket) do
changeset = socket.assigns.changeset
[bar_index, baz_index] = indexes |> String.split("-") |> Enum.map(&String.to_integer/1)
bars = Ecto.Changeset.get_field(changeset, :bars)
|> IO.inspect(label: "pre change")
|> List.update_at(bar_index, fn bar ->
Map.update!(bar, :bazs, fn bazs ->
List.delete_at(bazs, baz_index)
end)
end)
# we can see here that `:bazs` == []
|> IO.inspect(label: "post change")
# This is false as I expected
IO.inspect(bars == Ecto.Changeset.get_field(changeset, :bars), label: "Bars equals?")
# I expected this to be false, but it is true because it doesn't update the bars relation
IO.inspect(changeset == Ecto.Changeset.change(changeset, bars: bars), label: "Changeset equals?")
# The same problem with put_assoc
IO.inspect(changeset == Ecto.Changeset.put_assoc(changeset, :bars, bars), label: "Changeset 'put_assoc' equals?")
changeset = Ecto.Changeset.change(changeset, bars: bars)
{:noreply, assign(socket, changeset: changeset)}
end
end
The output:
pre change: [
%Project.Bar{
__meta__: #Ecto.Schema.Metadata<:built, "bars">,
bar: "bar",
bazs: [
%Project.Baz{
__meta__: #Ecto.Schema.Metadata<:built, "bazs">,
baz: "baz",
id: nil
}
],
id: nil
}
]
post change: [
%Project.Bar{
__meta__: #Ecto.Schema.Metadata<:built, "bars">,
bar: "bar",
bazs: [],
id: nil
}
]
Bars equals?: false
Changeset equals?: true
Changeset 'put_assoc' equals?: true
It seems like Ecto.Changeset.change
does not detect that the :bars
association changed. I use a similar approach to add a grandchild and it works perfectly, it detects the change and LiveView re-renders the form. I don’t know if it is a bug in Ecto or I’m not using the right function or approach—I admit the code it is quite complex just to delete a grandchild, if you have suggestion to make it easier I’m all ears
Thanks in advance