Psst. It would be super cool if you could try the new Phoenix Boilerplate!

Try now

Tutorial

View on Github

Nested model forms with Phoenix LiveView

This post was updated 01 May

formsliveviewphoenix

I 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 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.

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 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
    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 %>">&times</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 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 =
      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 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 =
      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 are persisted, there is a checkbox and I need to submit the form to actually delete the line


What are you working on?

If you want, you can send me a link to your Phoenix or Phoenix LiveView project so. Lets connect on Twitter or Linkedin.

- Andreas Eriksson, web developer since 2005

Related Tutorials

Published 14 Feb

Improving LiveView UX with Phoenix Channels - Tagging part 3

channelsformsliveviewphoenixtagging

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

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

formsliveviewphoenixtaggingtailwindtypeahead

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