Skip to content

Instantly share code, notes, and snippets.

@LostKobrakai
Last active April 12, 2024 18:56
Show Gist options
  • Star 34 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save LostKobrakai/ce5385bd118189a24d60893188612de9 to your computer and use it in GitHub Desktop.
Save LostKobrakai/ce5385bd118189a24d60893188612de9 to your computer and use it in GitHub Desktop.
Phoenix LiveView form with nested embeds and add/delete buttons
defmodule NestedWeb.FormLive do
use NestedWeb, :live_view
require Logger
defmodule Form do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :name, :string
embeds_many :cities, City, on_replace: :delete do
field :name, :string
end
end
def changeset(form, params) do
form
|> cast(params, [:name])
|> validate_required([:name])
# When string "[]" is detected, make it an empty list
# Doing that after the cast on `changeset.params` guarantees string keys
# Only works if `cast/4` is used though, which should be the case with forms
|> then(fn changeset ->
if changeset.params["cities"] == "[]" do
Map.update!(changeset, :params, &Map.put(&1, "cities", []))
else
changeset
end
end)
|> cast_embed(:cities, with: &city_changeset/2)
end
def city_changeset(city, params) do
city
|> cast(params, [:name])
|> validate_required([:name])
end
end
def render(assigns) do
~H"""
<.form for={@changeset} let={f} phx-change="validate" phx-submit="submit">
<%= label f, :name %>
<%= text_input f, :name %>
<%= error_tag f, :name %>
<fieldset>
<legend>Cities</legend>
<%# Hidden input will make sure "cities" is a key in `params` map for no cities to persist %>
<%# Needs to be before `inputs_for` to not overwrite cities if present %>
<%= hidden_input f, :cities, value: "[]" %>
<%= for f_city <- inputs_for(f, :cities) do %>
<div>
<%= hidden_inputs_for(f_city) %>
<%= label f_city, :name %>
<%= text_input f_city, :name %>
<%= error_tag f_city, :name %>
<button type="button" phx-click="delete-city" phx-value-index={f_city.index}>Delete</button>
</div>
<% end %>
<button type="button" phx-click="add-city">Add</button>
</fieldset>
<%= submit "Submit" %>
</.form>
"""
end
def mount(_, _, socket) do
base = %Form{
id: "4e4d0944-60b3-4a09-a075-008a94ce9b9e",
name: "Somebody",
cities: [
%Form.City{
id: "26d59961-3b19-4602-b40c-77a0703cedb5",
name: "Berlin"
},
%Form.City{
id: "330a8f72-3fb1-4352-acf2-d871803cd152",
name: "Singapour"
}
]
}
changeset = Form.changeset(base, %{})
{:ok, assign(socket, base: base, changeset: changeset)}
end
def handle_event("add-city", _, socket) do
socket =
update(socket, :changeset, fn changeset ->
existing = Ecto.Changeset.get_field(changeset, :cities, [])
Ecto.Changeset.put_embed(changeset, :cities, existing ++ [%{}])
end)
{:noreply, socket}
end
def handle_event("delete-city", %{"index" => index}, socket) do
index = String.to_integer(index)
socket =
update(socket, :changeset, fn changeset ->
existing = Ecto.Changeset.get_field(changeset, :cities, [])
Ecto.Changeset.put_embed(changeset, :cities, List.delete_at(existing, index))
end)
{:noreply, socket}
end
def handle_event("validate", %{"form" => params}, socket) do
changeset =
socket.assigns.base
|> Form.changeset(params)
|> struct!(action: :validate)
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("submit", %{"form" => params}, socket) do
changeset = Form.changeset(socket.assigns.base, params)
case Ecto.Changeset.apply_action(changeset, :insert) do
{:ok, data} ->
Logger.info("Submitted the following data: \n#{inspect(data, pretty: true)}")
socket = put_flash(socket, :info, "Submitted successfully")
{:noreply, assign(socket, changeset: changeset)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end
@LostKobrakai
Copy link
Author

@gtaranti It‘s not. Depending on the persistence setup, the id might only be assigned in the db. Sometimes embeds don‘t even used ids. This setup of using indexes makes the implementation work without needing to be aware of primary keys.

@gtaranti
Copy link

There is something wrong when you try to delete all nested cities.

Please, try it.

When you delete both 'Berlin' and 'Singapore' both cities are still submitted.
If you delete only 1, and there is another still remaining, there is no problem.
It only happens, when the new_cities is an empty list.

Do I miss anything?

@LostKobrakai
Copy link
Author

LostKobrakai commented Jun 16, 2022

@gtaranti I just revised the code to accomodate the usecase. The underlying issue here is the limitation of http, where x-www-form-urlencoded cannot encode an empty list value, so there's no way to differenciate between the cases of:

  • The form doesn't allow editing cities
  • The form does allow editing cities and the fact that none were sent is because all shall be removed

In this case given the changeset is known to be used exclusively for the latter case it can be accomodated for, but once this is no longer the case things become more tricky.

@gtaranti
Copy link

Thanks, but now is it possible to display existing cities on load?

@LostKobrakai
Copy link
Author

@gtaranti I've put up another revision, with reasonable tradeoffs from the form end of things. One could also make another parameter to changeset/2, which tells the function when the changeset is used as input to the form and when as an output.

@mlexs
Copy link

mlexs commented Aug 29, 2022

@LostKobrakai that's nice - thanks! I've noticed one issue tho. When invoking add-city (or my replacement) it will clear up all values from fields in the form (not saved values). how do preserve such data?

@milangupta1
Copy link

milangupta1 commented Jan 7, 2023

This is bothering me .. for all the beauty in how ecto and forms work together, why is this case not handled in cast_assoc() ? I don't understand the design of ecto/forms to the level you do, however, this just doesn't seem right to have to do this ?

  # When string "[]" is detected, make it an empty list
  # Doing that after the cast on `changeset.params` guarantees string keys
  # Only works if `cast/4` is used though, which should be the case with forms
  |> then(fn changeset ->
    if changeset.params["cities"] == "[]" do
      Map.update!(changeset, :params, &Map.put(&1, "cities", []))
    else
      changeset
    end
  end)

BTW> @LostKobrakai - Thank you for the amazing code example .. in the ecosystem, it is really difficult to find reference examples of how things should be done. You have achieved a mastery level here ..

@LostKobrakai
Copy link
Author

@milangupta1 This is not a fault of ecto, but of how html form encoding works. I've created a more in depth version of the above (working without that hack) here: https://kobrakai.de/kolumne/one-to-many-liveview-form

@dennym
Copy link

dennym commented Jun 29, 2023

What is update on #L105 ?

@LostKobrakai
Copy link
Author

@dennym
Copy link

dennym commented Jul 2, 2023

@dennym Are you asking which function it is? https://hexdocs.pm/phoenix_live_view/0.19.3/Phoenix.Component.html#update/3

Thank you very much. Sometimes its hard to tell where some functions come from...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment