One-to-Many LiveView Form
With Phoenix LiveView becoming more and more popular people try to build more dynamic forms than before. Often this involves forms, which allow editing a parent schema and a one-to-many relationship, where inputs are dynamically added and removed. For example consider a invoice schema, which can have many rows added.
There are however a few foot-guns when approaching forms like that. Some due
to how html forms work, but also some due to the historical use of Ecto.Changeset
to power forms. Changesets are great, but especially combined with LiveView they
can feel limiting.
So let’s consider the following form for creating a groceries list to send to someone via email – sending part non functional. This involves a root level input for the email address and zero or more rows of multiple inputs for defining the list.
Interactive Example
Open to see saved value
%GroceriesList{ id: "4e4d0944-60b3-4a09-a075-008a94ce9b9e", lines: [ %GroceriesList.Line{ id: "26d59961-3b19-4602-b40c-77a0703cedb5", item: "Melon", amount: 1 }, %GroceriesList.Line{ id: "330a8f72-3fb1-4352-acf2-d871803cd152", item: "Grapes", amount: 3 } ], email: "friend@example.com" }
The schema
To start from the beginning – we’ll need a schema to power our form. There
are schemaless changesets, but the Phoenix.HTML.Form
implementation for
changesets doesn’t support nested forms using schemaless changesets. Given
the example shown here is in memory only we’ll be using an embedded schema,
but a database backed schema works just as well.
Phoenix 1.7 added support for plain maps powering forms, which seems like a viable alternative as well. That option won’t be discussed here as part of updating this blog post though.
defmodule GroceriesList do
use Ecto.Schema
embedded_schema do
field(:email, :string)
embeds_many :lines, Line, on_replace: :delete do
field(:item, :string)
field(:amount, :integer)
end
end
end
Using embeds also allows us to inline the Line
embed, which is described
in more detail in the documentation.
To apply changes to the defined schema and validate the input there’s also the
need for changeset/2
type functions. These should be mostly straight forward to
anyone having worked with ecto
before, so this blog post won’t go into detail
what these functions specifically do. There’s again more to read in the documentation.
defmodule GroceriesList do
[…]
import Ecto.Changeset
def changeset(form, params) do
form
|> cast(params, [:email])
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
|> cast_embed(:lines, with: &line_changeset/2)
end
def line_changeset(city, params) do
city
|> cast(params, [:item, :amount])
|> validate_required([:item, :amount])
end
end
Setting up the LiveView
Getting to the meat of the topic – the LiveView itself. Starting from a mostly bare bones implementation.
defmodule GroceriesWeb.ListLive do
use GroceriesWeb, :live_view
@impl true
def render(assigns) do
[…]
end
@impl true
def mount(_, _, socket) do
base = %GroceriesList{
id: "4e4d0944-60b3-4a09-a075-008a94ce9b9e",
email: "friend@example.com",
lines: [
%GroceriesList.Line{
id: "26d59961-3b19-4602-b40c-77a0703cedb5",
item: "Melon",
amount: 1
},
%GroceriesList.Line{
id: "330a8f72-3fb1-4352-acf2-d871803cd152",
item: "Grapes",
amount: 3
}
]}
{:ok, init(socket, base)}
end
end
Usually base
would be fetched from the database. This example again runs
completely with data in memory, so there’s just some hardcoded initial data.
init/2
is a small helper, which generates the initial changeset behind the form,
but does also handle a few things needed just for this being in-memory.
Changing the id also means LV will reset its client side state around
the form properly on successful saves.
defp init(socket, base) do
base = autogenerate_missing_ids(base) # Mimic DB setting IDs
changeset = GroceriesList.changeset(base, %{})
assign(socket,
base: base,
form: to_form(changeset),
id: "form-#{System.unique_integer()}" # Reset form for LV
)
end
The Form
Let’s fill render/1
with some actual markup. First the outer form with the
:email
input, event handler configuration and submit button, but also a
<fieldset>
to wrap all the nested inputs.
<.simple_form
id={@id}
for={@form}
phx-change="validate"
phx-submit="submit">
<.input field={@form[:email]} label="Email" />
<fieldset class="flex flex-col gap-2">
<legend>Groceries</legend>
<.inputs_for :let={f_line} field={@form[:lines]}>
<.line f_line={f_line} />
</.inputs_for>
</fieldset>
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
The nested inputs themselves are extracted into a function component, which makes things a little easier to follow compared to one huge blob of html. It will also allow us computing assigns per row later.
<div>
<div class="flex gap-4 items-end">
<div class="grow">
<.input class="mt-0" field={@f_line[:item]} label="Item" />
</div>
<div class="grow">
<.input class="mt-0" field={@f_line[:amount]} type="number" label="Amount" />
</div>
</div>
</div>
There used to be the need to call Phoenix.HTML.Form.hidden_inputs_for/1
here
when using a manual for
comprehention with Phoenix.HTML.Form.inputs_for/2
,
but the newer function component Phoenix.Component.inputs_for/1
automatically
adds those. Those hidden inputs submit metadata like primary keys, so ecto can
map any changes back to existing data if available.
Getting things working
With the markup and the data backing the form out of the way we can get started making adding and removing lines work.
Adding a line
Lets start with adding new lines to the list. For that we’ll add a button within
the <fieldset>
wrapping the groceries list.
<fieldset class="flex flex-col gap-2">
[…]
<.button class="mt-2" type="button" phx-click="add-line">Add</.button>
</fieldset>
The phx-click
handler will send an event to the server to add a new line. How
to do that however is already a tricky topic, given how changesets work. Explaining
those requires a quick tangent:
Ecto.Changeset
in LiveView
There are two things to understand about Ecto.Changeset
s, which make working
with it feela bit bend over backwards.
- Changesets are not stateful as in they’re not mean to be continuously edited
The changeset API is a very functional one. Given a base and an input of of how things are meant to look like at the end ecto figures out all the necessary changes to get to that endresult. One can then validate all the changes to prevent disallowed invariant and eventually can check if the changes are good to go or not.
The API however is not stateful in the sense that after such a round of validation one could go back and add more changes and have the changeset know which errors won’t apply anymore. A changeset doesn’t keep track of which validations were applied end how, it only keeps their results – errors and metadata – around.
So whenever there is new input with changes to be validated the expectation is that a new changeset is created, which again is run through the same validation paths as the previous.
For forms in LiveView this means you don’t want to store data in a changeset,
which won’t be reflected back to a new changeset by how the form on the client is
updated. Creating a new changeset from just the form params
should always work.
- There is no imperative API for modifying list of associations or embeds
Changesets modify associations or embeds through a set based approach. The input to a changeset can just include how the list of associations or embeds is meant to look like after changes are applied and ecto figures out which items need to be added, which need to be update, which had no changes or which need to be deleted.
This is great for non-interactive clients, which have no means of modifying that list over time by e.g. by applying “delete item a” and later applying “delete item b”. This also overlaps with the previous point of being meant to create a new changeset each time the set of updates to be applied changes.
Any interactivity we add to a form with LiveView will be imperative however. We
also won’t have access to the forms params
in the related event handlers. That
requires updating the existing changeset on the LiveView, but also constrains how
that can happen.
Both of those facts about changeset – which to be fair were never build to power interactive frontend forms – don’t map too well to what LiveView allows people to build forms.
The mentioned properties of changesets means we’ll need to be thoughful in what we do for handling the additional events related to our LiveView form.
def handle_event("add-line", _, socket) do
socket =
update(socket, :form, fn %{source: changeset} ->
existing = Ecto.Changeset.get_embed(changeset, :lines)
changeset = Ecto.Changeset.put_embed(changeset, :lines, existing ++ [%{}])
to_form(changeset)
end)
{:noreply, socket}
end
In the event handler we want to add a line to our form, but also don’t want to
loose any existing changes present in the form, but not yet applied to our
base
data. We use get_embed
to get whatever the changeset considers the
current list of lines, including all known changes and then append a new item.
That new item doesn’t have changes, so it’s an empty map. The modified list is
then passed to put_embed
to be set on the changeset.
This feels like imperative editing the changeset in place and it is. But the goal
really is that the re-rendered form on the client includes new inputs for this
new item. So that they’re visible to the user and subsequent phx-validate
events
include those params
to be able to build a changeset. No other code of ours
should need to look at that added item in the changeset again.
Remove a line
The second step is the inverse to the previous: removing lines. This one also has a few complexities:
Identifying the line to delete
The usual answer to identifying data especially database records would be using
primary keys, most often the id
of a record. The form we’re looking at however
allows adding new lines, which might only get a fixed id assigned when persisted.
So there might be many lines without an id
yet. Also not every database record
has a primary key.
A more flexible approach is using @f_line.index
, which inputs_for
sets.
That value is available and works with any schema without our code needing to
depends on any of their details, which is great.
Deleting existing records
HTML forms cannot send a value of “empty list”. The encodings for form data only allow for sending data on a form, but not the lack of data.
If there are two existing lines for our form, we delete the inputs for the first line and submit the form everything will work just fine.
If we however remove all the line inputs and submit the form the params
would look
like %{email: "…"}
instead of %{email: "…", lines: []}
. To ecto %{email: "…"}
means “no changes to lines” rather than “delete existing lines”. That’s obviously
not what we want.
There are few ways to work around that issue, but the cleanest is to be more more explicit about deletions. Instead of removing inputs for existing lines from the form immediatelly, we instead update a flag on to be deleted lines, which makes them be deleted when the form is saved.
For that to happen we need to go back and update the schema and its changeset/2
function slightly to make the delete on save part work.
embeds_many :lines, Line, on_replace: :delete do
[…]
field(:delete, :boolean, virtual: true)
end
[…]
def line_changeset(city, params) do
changeset =
city
|> cast(params, [:item, :amount, :delete])
|> validate_required([:item, :amount])
if get_change(changeset, :delete) do
%{changeset | action: :delete}
else
changeset
end
end
With that out of the way we can add the button to remove a line as well as the
accompanying event handler. We’ll also add a new computed assign to the line/1
function component, so the few places needing the be adjusted for lines marked
to be deleted have a single assign to use.
assigns = assign(assigns, :deleted, Phoenix.HTML.Form.input_value(assigns.f_line, :delete) == true)
For educational purposes I only dropped the opacity on existing rows flagged for deletion, instead of hiding them completely. Feel free to adjust as needed.
<div class={if(@deleted, do: "opacity-50")}>
[…]
<input
type="hidden"
name={Phoenix.HTML.Form.input_name(@f_line, :delete)}
value={to_string(Phoenix.HTML.Form.input_value(@f_line, :delete))}
/>
<div class="flex gap-4 items-end">
[…]
<.button
class="grow-0"
type="button"
phx-click="delete-line"
phx-value-index={@f_line.index}
disabled={@deleted}
>
Delete
</.button>
</div>
</div>
The event handler for deleting a line is conceptionally similar to the one for
adding one. We again use the get_embed
to fetch all current lines, split out
the one to delete and check if it’s one already existing in base
or not.
Here we check for the presense of an id
, which is unfortunate, but for embeds
there’s no good way to check if a line was part of base
or was added to the form
later. For database backed schemas you can consider using Ecto.get_meta(schema, :state)
.
For existing lines the event handler then marks the line as :deleted
, while any
other lines are fine to just be removed from the changeset immediatelly.
def handle_event("delete-line", %{"index" => index}, socket) do
index = String.to_integer(index)
socket =
update(socket, :form, fn %{source: changeset} ->
existing = Ecto.Changeset.get_embed(changeset, :lines)
{to_delete, rest} = List.pop_at(existing, index)
lines =
if Ecto.Changeset.change(to_delete).data.id do
List.replace_at(existing, index, Ecto.Changeset.change(to_delete, delete: true))
else
rest
end
changeset
|> Ecto.Changeset.put_embed(:lines, lines)
|> to_form()
end)
{:noreply, socket}
end
Validating and saving the form
We went to great length to consider how forms and changesets work before
implementing adding and removing lines. For validating and saving the whole form
this pays off, as the event handlers for phx-change
and phx-submit
of our form
won’t look any different as they would for most other LV forms:
def handle_event("validate", %{"groceries_list" => params}, socket) do
changeset =
socket.assigns.base
|> GroceriesList.changeset(params)
|> struct!(action: :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("submit", %{"groceries_list" => params}, socket) do
changeset = GroceriesList.changeset(socket.assigns.base, params)
case Ecto.Changeset.apply_action(changeset, :insert) do
{:ok, data} ->
socket = put_flash(socket, :info, "Submitted successfully")
{:noreply, init(socket, data)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
The params
supplied by our form will include all the details we need to
validate the top level inputs, but also any lines. We won’t loose any not
yet saved lines on validation and also will have any deleted lines be
properly deleted, even if there are no more lines left on the form.
From here there could be additional features added like for example ignoring newly added lines, which have none of their inputs filled.
If you want to play with this you can look at this example repo, which actually stores data in the database: https://github.com/LostKobrakai/one-to-many-form
2023-02-27: Updated to work with phoenix 1.7.0 form changes.
2023-01-17: Replaced usage of Ecto.Changeset.get_field/3
in "add-line"
and
"delete-line"
event handlers with custom function using Ecto.Changeset.get_change/3
with a fallback of Ecto.Changeset.get_field/3
. This fixes a bug, where adding
or removing a line would make changes in other lines be “forgotten”.
2023-07-26: Replaced usage of the Ecto.Changeset.get_change/3
and fallback
to Ecto.Changeset.get_field/3
helper with Ecto.Changeset.get_embed/3
as
released with ecto 3.10.