How to Dynamically Add and Remove Embedded Item Inputs in a Form Using sort_param and drop_param

Hands at a keyboard

Elixir can boost your digital product, whether you need to reach hundreds of thousands of fans or meet your users’ high expectations. Book a consult today to learn how we can help you reach digital product success.

In this article, I’m going to demonstrate handling dynamic lists of embedded item inputs, interactively, in web forms using Phoenix LiveView features and zero JavaScript.

Building the Application

GitHub Repo

First, let’s generate some default stuff provided “for free” from the Phoenix framework.

mix phx.new formation
cd formation
mix ecto.create
mix phx.gen.live Deli Order orders name:string customer:string price:float status:enum:draft:pending:completed:canceled items

Be sure to copy/paste the suggested routes to my router.ex file. Next let’s generate the embedded items:

mix phx.gen.embedded Deli.Item name:string price:float quantity:integer

Before running the migration, change it so Postgres stores Order items as a jsonb field instead of a string, in priv/repo/migrations/[timestamp]_create_orders.exs

create table(:orders) do
  add :customer, :string
  add :items, :jsonb, default: "[]"
  add :name, :string
  add :price, :float
  add :status, :string

  timestamps(type: :utc_datetime)
end

Now we can run the migration and start the Phoenix server:

mix ecto.migrate
mix phx.server

Visiting <http: //localhost:4000/orders/new> presents us with a friendly modal:

An image of an order form

But there’s a problem. “Items” is supposed to be an embedded list of objects (maps), not a single text field.

First our Order schema needs to specify that it embeds many item, but that items (and status) are not required.

lib/formation/deli/order.ex

defmodule Formation.Deli.Order do
  use Ecto.Schema
  import Ecto.Changeset

  @permitted [:name, :customer, :price, :status]
  @required [:name, :customer]

  schema "orders" do
    field :name, :string
    field :status, Ecto.Enum, values: [:draft, :pending, :completed, :canceled]
    field :customer, :string
    field :price, :float

    embeds_many :items, Formation.Deli.Item, on_replace: :delete

    timestamps()
  end


  @doc false
  def changeset(order, attrs) do
    order
    |> cast(attrs, @permitted)
    |> cast_embed(:items)
    |> validate_required(@required)
  end
end

In the form that Phoenix generated for us, a simple_form core component is used, and it is sufficient to handle what we need.

The default <.input field={@form[:items]} type="text" label="Items" /> needs to be replaced with something dynamic. We can use the built-in LiveView component <.inputs_for> to generate a kind of “sub-form” for the nested fields of our dynamic list.

Here’s my simple_form with embedded items (within lib/formation_web/live/order_live/form_component.ex):

<.simple_form for={@form} id="order-form" phx-target={@myself} phx-change="validate" phx-submit="save">
  <.input field={@form[:name]} type="text" label="Name" />
  <.input field={@form[:customer]} type="text" label="Customer" />
  <.input field={@form[:price]} type="number" label="Price" step="any" />
  <.input field={@form[:status]} type="select" label="Status" prompt="Choose a value"
    options={Ecto.Enum.values(Formation.Deli.Order, :status)} />

  <% # Items %>
    <h2 class="pt-4 text-lg font-medium text-gray-900">Items</h2>
    <div class="mt-2 flex flex-col">
      <.inputs_for :let={item_f} field={@form[:items]}>
        <div class="mt-2 flex items-center justify-between gap-6">
          <.input field={item_f[:name]} type="text" label="Name" />
          <.input field={item_f[:price]} type="number" label="Price" step="any" />
          <.input field={item_f[:quantity]} type="number" label="Quantity" />
        </div>
      </.inputs_for>
    </div>

    <:actions>
      <.button phx-disable-with="Saving...">Save Order</.button>
    </:actions>
</.simple_form>

NOTE: I’m removing the “Items” rows, for now, from lib/formation_web/live/order_live/index.html.heex and lib/formation_web/live/order_live/show.html.heex so they don’t throw errors when Phoenix tries to render a list as a string.


Adding New Items

The “new” way to dynamically add and remove items from an embedded list takes advantage of Ecto v3.10 (April 2023) sort_param and drop_param options. Although sort_param is designed for, that’s right, sorting, it can also be used to add new items since “Unknown indexes are considered to be new entries.”

This can be accomplished in 3 steps:

  1. include sort_param in the parent changeset cast_embed call
  2. include a hidden input within the “items” sub-form to link the “sort” param to the item’s index in the form
  3. create a clickable label that activates the sort param on the order

Step 1: lib/formation/deli/order.ex

def changeset(order, attrs) do
  order
  |> cast(attrs, @permitted)
  |> cast_embed(:items,
    sort_param: :items_sort,
    drop_param: :items_drop
  )
  |> validate_required(@required)
end

NOTE: I’m including the drop_param as well in this step so it’s there when we need it below.

Step 2: lib/formation_web/live/order_live/form_component.ex Add this hidden input above the other inputs within the <.inputs_for> block:

<input type="hidden" name="order[items_sort][]" value={item_f.index} />
<.input field={item_f[:name]} type="text" label="Name" />
<.input field={item_f[:price]} type="number" label="Price" step="any" />
<.input field={item_f[:quantity]} type="number" label="Quantity" />

Step 3: lib/formation_web/live/order_live/form_component.ex (again) I put this label+checkbox inside of my :actions slot and styled it with Tailwind classes to look and act like a button:

<:actions>
  <label class={[
    "py-2 px-3 inline-block cursor-pointer bg-green-500 hover:bg-green-700",
    "rounded-lg text-center text-white text-sm font-semibold leading-6"
  ]}>
    <input type="checkbox" name="order[items_sort][]" class="hidden" /> Add Item
  </label>
  <.button phx-disable-with="Saving...">Save Order</.button>
</:actions>

An image of an order form


Removing Items

Until very recently, it was a bit more awkward to dynamically remove embedded items in LiveView. Read the blog posts referenced at the top of this article for examples of doing this by including a :delete attribute to the item schema, and conditionally appending a :delete action to the changeset when that attribute is set to true. There was not a great way to prune embedded items using Ecto Changeset. Ecto 3.10 (April 2023) introduced the drop_param option that makes this process much simpler. In Sept 2023, Jose included examples of the drop_param usage for dynamically removing embedded form inputs in the LiveView Phoenix.Component docs.

This is going to be very similar to what we just did to add items. My order.ex already has the drop param :items_drop so we just need to update the form.

Step 1: update order.ex (done)

Step 2: lib/formation_web/live/order_live/form_component.ex Create a clickable label within the <.inputs_for> block that contains a hidden checkbox:

<.inputs_for :let={item_f} field={@form[:items]}>
  <div class="mt-2 flex items-center justify-between gap-6">
    <input type="hidden" name="order[items_sort][]" value={item_f.index} />
    <.input field={item_f[:name]} type="text" label="Name" />
    <.input field={item_f[:price]} type="number" label="Price" step="any" />
    <.input field={item_f[:quantity]} type="number" label="Quantity" />
    <label>
      <input type="checkbox" name="order[items_drop][]" value={item_f.index} class="hidden" />
      <.icon name="hero-x-mark" class="w-8 h-8 relative top-4 bg-red-500 hover:bg-red-700 hover:cursor-pointer" />
    </label>
  </div>
</.inputs_for>

Step 3: lib/formation_web/live/order_live/form_component.ex (again) Include this hidden input somewhere in the form to track the item(s) to drop:

<input type="hidden" name="order[items_drop][]" />

And that’s it!

Now your users can dynamically add and delete items to their heart’s content:

An image of an order form


Wrap Up

The Phoenix Way for dynamically adding and removing embedded items in a LiveView form is very different in late 2023 than it was before. This new pattern simplifies things considerably and uses Ecto built-in options to manage the embedded items.


Further Reading

I would like to acknowledge a couple excellent posts on this subject, and the docs, that helped me along the way:

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box