How to add or remove has_many association in LiveView form?

Hi there!

I have Order schema:

defmodule Order do
  has_many :items, Item, on_replace: :delete_if_exists

  def changeset(order, attrs) do
    order
    |> cast(attrs, attrs_list)
    |> cast_assoc(:items, with: Item.changeset/2)
  end
end

What would be the best way to add or remove items to the order form?

Currently, I have function which modifies changeset, but I don’t feel it’s the right way

def add_item_to_changeset(changeset, item) do
  items = get_field(changeset, :items)
  |> Enum.concat([Item.changeset(item)])

  changeset
  |> put_assoc(:items, items)
end
1 Like

Hi :wave:

It depends… Do you want to add many Items at once and update the whole set of Items of an Order as a whole (adding new ones and potentially removing existing items)? Then you can use use cast_assoc and put_assoc and friends for the :items relation to update the whole set of Items.

If you only want to add a single Item to an existing Order, then you can better create an Item with the order_id set correctly.

The difference between the two approaches is explained here as part of the put_assoc documentation.

If this is not enough input to get to a solution, you might want to explain how you’re using that add_item_to_changeset function (in a LiveView as part of a phx-click handler, or as a function in one of your contexts, or …), and how your UI looks like (to figure out what you’re actually doing).

1 Like
9 Likes

but in the case that have more one level of one-to-many? like lines having many items: items(name,owner)?

Applying the approaches outlined in the article should scale to nested associations/embeds as well.

2 Likes

Great post, thanks.

Awesome post, it helped me a ton.

I still ran into some little nuances though. I think it all has to do with reusing dom ids on input fields.

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

So if the item doesn’t have a primary key, it’s removed from the list of associated things all together. This confuses the LiveView, especially around phoenix-feedback-for. For example, if I delete line number 3, then line number 4 now gets line 3’s old dom id when the form is rerendered.

I had other weird issues that I think are related to this same thing. Like deleting lines and other lines that were shifted up would not have their values properly rendered. Also input focus issues.

All these little things went away when I kept all the deleted items on the page (even ones without primary key) and just hid them instead.

2 Likes

Very nice post @LostKobrakai , thanks.

I was wondering, is there a particular reason to store the Form in @form instead of just using the changeset? It seems like everywhere you’re either deconstructing the form %{source: changeset} or constructing it to_form(changeset)? It would seem simpler using just the changeset, but maybe I’m missing something?

You need the form for optimal change tracking of forms in LV. The form already includes the complete changeset, so no need to store an additional copy if it.

Interesting. I’d like to understand more, is there documentation on this? For instance, using the code of your blog post, do you have an example scenario where just storing the changeset would cause issues?

I don‘t have a good resource at hand, but the tldr is that @form[:field] will allow tracking changes per field. Using :let={f} means any change to the form will trigger a rerender of the whole form, not just pieces of it.

Eventually it comes down to what is mentioned in Assigns and HEEx templates — Phoenix LiveView v0.20.1, where you don‘t want to call functions in templates.

I’m confused. That section say to avoid passing blocks when calling functions in templates, is that what you are referring to? That does not seem related to storing a Form in the @form assign vs storing only the Changeset.

I might not have been clear in my question. Here’s a PR for what I am talking about: Simplify by storing the changeset instead of the form by marcandre · Pull Request #7 · LostKobrakai/one-to-many-form · GitHub

Yes, the docs don‘t explicitly call out forms, but passing a changeset to <.form for={@changeset} …> means the component calls to_form(changeset) and supplies the result back as :let={f}. That f is not granularly change tracked. Any change to the changeset will make everything depending on that f update.

Doing the to_form in advance and storing the results in assigns + dropping the usage of :let={f} in favor of @form[:field] means the changeset can update just a single fields value and only that single form field updates, unchanged fields won‘t.

You cannot build a form just from @changeset without running it through to_form/Phoenix.HTML.FormData.

Given you always need the form struct and it includes the original source, the changeset, and the improvements to change tracking by having the form struct be an assign it very much makes sense to not keep the changeset individually as an assign.

2 Likes

Looking at the PR again I think I missed to refactor the :let part to @form[:field] when updating this from previous LV versions.

Edit:

Great, thanks, makes more sense with these changes in. I also found where this is mentioned in the docs.

Thank you @LostKobrakai :heart: