Liveview dynamic form - adding items from list

Say I want to make a form for making custom pizzas (the app is not actually about pizzas, but it is an example that is easier to understand).

I have toppings (say cheese, pepper, anchovies, etc) that are shown in a list.
Customers are allowed to create custom pizzas, by giving the pizza a name, and selecting any amount of toppings from a list of toppings that is displayed next to the form.

I have created the following Ecto schemas:

schema "toppings" do
    field :name, :string
  end

schema "pizza_toppings" do
    belongs_to :pizza, Pizza
    belongs_to :topping, Topping
end

schema "pizzas" do
    field :name, :string
    has_many :pizza_toppings, PizzaTopping
    has_many :toppings, through: [:pizza_toppings, :topping]
end

I can not figure out the code that goes in the handle_event("add-topping", %{"topping_id" => topping_id}, socket) function (that is called when clicking on a specific topping) to actually add the topping to the changeset and have it validated by the form. I guess this is due to not quite grokking how changesets work…

I 've tried adapting the guide here One-to-Many LiveView Form | Benjamin Milde but have not succeded.

1 Like

Based off of the guide you’re trying to adapt and your join schema, maybe try something like this?

def handle_event("add-topping", %{"topping_id" => topping_id}, socket) do
  pizza_topping = %PizzaTopping{topping_id: topping_id}

  socket =
    update(socket, :form, fn %{source: changeset} ->
      existing = get_change_or_field(changeset, :pizza_toppings)
      changeset = Ecto.Changeset.put_assoc(changeset, :pizza_toppings, existing ++ [pizza_topping])
      to_form(changeset)
    end)

  {:noreply, socket}
end

defp get_change_or_field(changeset, field) do
  with nil <- Ecto.Changeset.get_change(changeset, field) do
    Ecto.Changeset.get_field(changeset, field, [])
  end
end

See: Adding tags to a post | Ecto.Changeset

There seems to be an understandable disconnect between Ecto and LiveView that likely exists because Ecto predates LiveView and was designed for the stateless paradigm of HTTP requests handled by conventional controllers. Subsequently, there is no default interface/function to incrementally build/add to an existing association within a parent changeset. Basically, something like Ecto.Changeset.add_to_assoc/3 doesn’t exist – but if it did, we could then do something like this:

def handle_event("add-topping", %{"topping_id" => topping_id}, socket) do
   {:noreply,
     update(socket, :form, fn %{source: changeset} ->
       changeset
       |> Ecto.Changeset.add_to_assoc(:pizza_toppings, %PizzaTopping{topping_id: topping_id})
       |> to_form
     end)
   }
end
3 Likes

Thanks, that got me a bit further, I think I might be able to piece it together.

Indeed, and as you say, it is understandable why that is. As it is, the interface is really cumbersome.

I think it might be easier at this time to “just” keep the nested resource (toppings) in different variables in the assigns and manually manage adding and removing them, and only deal with Ecto, changesets etc, on form submission (or whenever you actually want to persist something to the DB).

I’ve seen the developers mentioning a new form API is on the way, (https://youtu.be/9-rqBLjr5Eo?t=2599) I hope that simplifies this part.

Ecto 3.10 (which I hoped to be out by now, but alas it isn’t) will include Ecto.Changeset.get_assoc, which in combination with Ecto.Changeset.put_assoc should make modification of assocs a bit more comfortable.

Is there a reason to go with nested embeds? These make sense if you have multiple fields per row, but you don’t seem to have that. I’d consider using checkboxes to toggle and transform the resulting list to a nested structure only on submit. I’ve a small POC for a checkboxes component and there’s a fly.io blogpost for something similar:
Scratchpad | Benjamin Milde
Making a CheckboxGroup Input · The Phoenix Files

4 Likes

Not sure if you mean embeds as in the Ecto sense, I’m using associations and join tables. I based it of the pull request/fork of your repo: Switch to has_many assocs + add ordering by tmjoen · Pull Request #3 · LostKobrakai/one-to-many-form · GitHub

I figured doing it this way (with nested resources in the form), using ecto, changesets and to_form etc was the “Phoenix Way”, and that it also would enable “automagic” live form validations based on Ecto schemas. It seems to be doable, but it is not really “ergonomic”.

Yes transforming the selections on submit is my thinking now. I hadn’t considered using checkboxes, since the items (“toppings”) appear in a list to the side of the form, and I want to “physically” (on the screen) “move” the selected items so they appear inline with the rest of the form, and also want to keep the order in which they are selected.

Oh neat, thanks for the heads up! That’d definitely improve ergonomics of modifying associations.

An Ecto.Changeset.get_assoc reminds me of Kernel.get_in so any thoughts on a Ecto.Changeset.put_in_assoc or Ecto.Changeset.update_in_assoc that would resemble Kernel.put_in and Kernel.update_in?

Hmm, just thinking out loud – that interaction could be supported by autohiding the checkboxes in the form and checking as well as showing them when handling the click from the sidebar list.

Yes, I’m starting to think that might be good way of implementing it. Thanks!

1 Like

There it is!

https://hexdocs.pm/ecto/changelog.html#v3-10-0-2023-04-10

4 Likes

I’m trying to do a similar thing, and I think I’m almost there. Basically, I made a checkbox group like described in the previously mentioned fly.io blog post, with the following changes in the input component:

def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
  assigns
  # ...
  |> assign_new(:value, fn -> process_value(field) end)
  |> input()
end

defp process_value(%Phoenix.HTML.FormField{value: [%Ecto.Changeset{} = _ | _]} = field) do
  Ecto.Changeset.get_assoc(field.form.source, field.field, :struct)
end
defp process_value(field), do: field.value

And, in the checkgroup input type, instead of checked={value in @value}, I have checked={Enum.any?(@value, &(&1.id == value))} (this should be changed to be more generic, but that’ll do for now…)

It almost works correctly, the only issue I have is when I load an existing pizza to edit it, no toppings are selected. I think my changeset is not working correctly, because all the related items have an action: :replace in the initial changeset. It looks a little bit like this:

def changeset(pizza, attrs) do
  toppings =
    attrs
    |> Map.get("toppings", [])
    |> Enum.filter(& not (&1 == ""))
    |> Toppings.list_by_ids()

  pizza
  |> cast(attrs, @required)
  |> put_assoc(:toppings, toppings)
  |> validate_required(@required)
end

Hmm, have you confirmed that this works as expected? In the aformentioned blog post, value was a string e.g. “biography” from a hardcoded list coming from the options assign.

<.checkgroup field={@form[:genres]} label="Genres" options={Book.genre_options()} />

In your HEEx template, is the equivalent options assign for the .checkgroup component passed a list of tuples with an id rather than a string?

Ho yeah, excuse me, I pass tuples like this:

<.checkgroup
      class="my-2"
      field={@form[:toppings]
      options={Enum.map(@toppings, &{&1.label, &1.id})} />

Ahh, gotcha – how curious…

checked={Enum.any?(@value, &(&1.id == value))}

Assuming all the checkboxes in the generated HTML have checked=false, maybe there’s somehow a type mismatch which results in a false negative? It might be worth throwing in some IO.inspects here, maybe something like:
checked={Enum.any?(@value, &(&1.id |> IO.inspect(label: "&1.id: ") == value |> IO.inspect(label: "value: ")))}

I tried the following:

checked={Phoenix.HTML.Form.normalize_value("checkbox", Enum.any?(@value, &(&1.id == value)))}

which is probably cleaner, but it doesn’t work better.