Nested model forms with Phoenix LiveView

This post was updated 01 May - 2020

I my last article, [I set up a relationship between products and variants](https://fullstackphoenix.com/tutorials/setup-a-has-many-belongs-to-in-phoenix). 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.


I want to the form to be able to add 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.



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))

**Note, that I want to preload variants in the specific order they where 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 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 of 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 on 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
    |> 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()

  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}


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 class="form-group px-1 w-2/6">
        <%= label v, :value %>
        <%= text_input v, :value, class: "form-control" %>
        <%= error_tag v, :value %>

      <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 %>">&times</a>
        <% end %>
  <% 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 its 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 %>">&times</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/tutorial_web/live/product_form_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 =
      |> Enum.concat([
        Products.change_variant(%Variant{temp_id: get_temp_id()}) # NOTE temp_id

    changeset =
      |> Ecto.Changeset.put_assoc(:variants, variants)

    {:noreply, assign(socket, changeset: changeset)}
  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 was 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 ont where `remove_id` matches the assigned `temp_id`. And then but the list back into the changeset. 

  def handle_event("remove-variant", %{"remove" => remove_id}, socket) do
    variants =
      |> Enum.reject(fn %{data: variant} ->
        variant.temp_id == remove_id

    changeset =
      |> Ecto.Changeset.put_assoc(:variants, variants)

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

### 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 are persisted, there is a checkbox and I need to submit the form to actually delete the line


Related Tutorials

Published 14 Feb - 2020
Updated 01 May - 2020

Improving LiveView UX with Phoenix Channels - Tagging part 3

In the previous tutorial I set up the tagging interface. It had however a small issue. If I added a tag, it didnt really refocus on the input so I n..

Published 13 Feb - 2020
Updated 01 May - 2020

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

In the previous tutorial, I set up the the backend for being able to add tags to products. I have also written a tutorial about adding a LiveView an..