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
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:
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
andlib/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:
- include
sort_param
in the parent changesetcast_embed
call - include a hidden input within the “items” sub-form to link the “sort” param to the item’s index in the form
- 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>
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 (done)order.ex
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:
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:
- Nested model forms with Phoenix LiveView, from Fullstack Phoenix (May 2020)
- One-to-Many LiveView Form, from Benjamin Milde (December 2022)
- Dynamically adding and removing inputs from the LiveView docs for Phoenix.Component (updated Sept 2023)