Tutorial
Nested model forms with Phoenix LiveView
This post was updated 01 May - 2020
In my last article, I set up a relationship between products and variants. But what I didn't go through was to setup a form where you can manage the variants.
The topic for this tutorial is to show you how to setup a nested model form with Phoenix LiveView where you can add and remove fields on the fly.
RESULT
I want the form to be able to dynamically add several lines of variant forms and save them in one shot. I also want to be able to remove individual lines that are not yet persisted. However, if the lines are persisted and I edit the lines, I need to be able to mark them as being deleted.
STEP 1 - PRELOADING AND SETUP
First thing I need to do is to preload variants when I get one product. Open lib/tutorial/products.ex
And change def get_product!(id), do: Repo.get!(Product, id)
to:
def get_product!(id) do
Repo.get!(Product, id)
|> Repo.preload(variants: from(v in Variant, order_by: v.id))
end
Note, that I want to preload variants in the specific order they were created in. That ensures better UX when using the form later.
To be able for a product changeset to handle the variants, I need to tell the changeset to do just that. Open lib/tutorial/products/product.ex
and in the changeset, add the line:
|> cast_assoc(:variants)
Now, when the attributes contain the "variants"
key, the attached data will be handled by the changeset in the Variant
-module. Make sure that has_many
-relationship is setup.
Speaking of Variant
, we need to do changes there as well. To be able to delete a nested resource from a parent form, you need to add a virtual :delete
field. And then in the changeset, look if that is filled in, and if so, delete it.
For my example, I also need another virtual field so I can keep track of the non-persisted lines. I will call that field :temp_id
.
So, in the schema, add:
field :temp_id, :string, virtual: true
field :delete, :boolean, virtual: true
And in the changeset, change to:
def changeset(variant, attrs) do
variant
|> Map.put(:temp_id, (variant.temp_id || attrs["temp_id"])) # So its persisted
|> cast(attrs, [:name, :value, :delete]) # Add delete here
|> validate_required([:name, :value])
|> unique_constraint(:name, name: :variants_name_value_product_id_index)
|> maybe_mark_for_deletion()
end
defp maybe_mark_for_deletion(%{data: %{id: nil}} = changeset), do: changeset
defp maybe_mark_for_deletion(changeset) do
if get_change(changeset, :delete) do
%{changeset | action: :delete}
else
changeset
end
end
STEP 2 - LIVEVIEW AND FRONTEND CHANGES
Next step is to setup the actual form. I will use the
Open lib/tutorial_web/templates/product/form.html.leex
and add:
<%= form_for @changeset, @action, [phx_change: :validate, class: "block", csrf_token: @csrf_token], fn f -> %>
... rest of the product fields
<p class="mt-4 mb-2 font-bold">Variants</p>
<%= inputs_for f, :variants, fn v -> %>
<div class="flex flex-wrap -mx-1 overflow-hidden">
<div class="form-group px-1 w-3/6">
<%= label v, :name %>
<%= text_input v, :name, class: "form-control" %>
<%= error_tag v, :name %>
</div>
<div class="form-group px-1 w-2/6">
<%= label v, :value %>
<%= text_input v, :value, class: "form-control" %>
<%= error_tag v, :value %>
</div>
<div class="form-group px-1 w-1/6">
<%= label v, :delete %><br>
<%= if is_nil(v.data.temp_id) do %>
<%= checkbox v, :delete %>
<% else %>
<%= hidden_input v, :temp_id %>
<a href="#" phx-click="remove-variant" phx-value-remove="<%= v.data.temp_id %>">×</a>
<% end %>
</div>
</div>
<% end %>
<a href="#" phx-click="add-variant">Add a variant</a>
... submit button that was already in the form
<% end %>
And the form should now look like this with the link to add fields in the bottom.
However, since we have added phx-click="add-variant"
we need to implement the changes in the LiveView component. Also note that there are two different ways of deleting a row depending if it's persisted or not:
<%= if is_nil(v.data.temp_id) do %>
<%= checkbox v, :delete %>
<% else %>
<%= hidden_input v, :temp_id %>
<a href="#" phx-click="remove-variant" phx-value-remove="<%= v.data.temp_id %>">×</a>
<% end %>
And only new lines get the temp_id
.
So, open the LiveView component that I already have in place for the form lib/tutorialweb/live/productform_live.ex
For convenience, add an alias to Variant
in the top:
alias Tutorial.Products.Variant
Also, in the bottom, I need to make sure I initialize a new Product with an empty array of variants.
So change the line to:
def get_product(_product_params), do: %Product{variants: []}
Then add the "add-variant"
event. Basically, what I want to do is to take all existing variants and append a new one to the changeset. And that new one is unpersisted and get a temp_id
. I need that to know which one to remove.
def handle_event("add-variant", _, socket) do
existing_variants = Map.get(socket.assigns.changeset.changes, :variants, socket.assigns.product.variants)
variants =
existing_variants
|> Enum.concat([
Products.change_variant(%Variant{temp_id: get_temp_id()}) # NOTE temp_id
])
changeset =
socket.assigns.changeset
|> Ecto.Changeset.put_assoc(:variants, variants)
{:noreply, assign(socket, changeset: changeset)}
end
# JUST TO GENERATE A RANDOM STRING
defp get_temp_id, do: :crypto.strong_rand_bytes(5) |> Base.url_encode64 |> binary_part(0, 5)
NOTE the complicated line:
existing_variants = Map.get(socket.assigns.changeset.changes, :variants, socket.assigns.product.variants)
I do this because if I already have started manipulating the form, I want to use the updated variants from the changeset. Otherwise, fallback to the variants that were preloaded with the product.
Last step here is to the "remove-variant"
. Here, I will just iterate over the variants in the changeset and reject the one where removeid
matches the assigned tempid
. And then put the list back into the changeset.
def handle_event("remove-variant", %{"remove" => remove_id}, socket) do
variants =
socket.assigns.changeset.changes.variants
|> Enum.reject(fn %{data: variant} ->
variant.temp_id == remove_id
end)
changeset =
socket.assigns.changeset
|> Ecto.Changeset.put_assoc(:variants, variants)
{:noreply, assign(socket, changeset: changeset)}
end
Final result
Back in the form, I should now be able to add a new variant line:
And if I click the cross in the delete column, it should be removed. However, if I am in edit-mode and the line item is persisted, there is a checkbox and I need to submit the form to actually delete the line.