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 |
@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.
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?
@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.
Thanks, but now is it possible to display existing cities on load?
@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.
Nice!
By inspecting changeset, I saw that when adding cities to the list their ID is nil.
Isn't it a problem?