Deleting associated lists via a single form

I have a question related to updating a has_many association via a form.

I have a User that has many PhoneNumbers. In a single form for the user I’m using inputs_for to render all of the user’s phone numbers as well, so the submission of this form will update the user and the associated phone numbers.

Each phone number is rendered with it’s own Delete button next to it. Clicking the Delete button will dynamically delete the phone number’s input fields from the form (including the hidden :id input) so that when I save the form they get deleted.

This is working correctly as long as there is at least one remaining phone number in the form. The problem I’m having is that if I delete all of the phone numbers from the form, when I save, my payload will not include any phone_numbers fields for the user, and thus won’t be trying to set user.phone_numbers to an empty list. I could try to make my JS smarter and add some hidden field that sets user[phone_numbers] to an empty list somehow (via a string since forms can’t pass Elixir literals?), but that feels kludgey.

I could possibly modify my changeset to set the association to empty if the field isn’t present I suppose?

Has anyone dealt with this before?

Thanks!

I discovered scrub_params over the weekend and was hoping it would solve my problem, converting my form submission’s user[phone_numbers] from "" into [] in Elixir, but instead I get the following error in the changeset.

[phone_numbers: {"is invalid", [type: {:array, :map}]}]

If anyone has any ideas I’d appreciate the help. Thanks.

I should also clarify that I’m using on_replace: :delete in the has_many association.

has_many :phone_numbers, App.PhoneNumber, on_replace: :delete

It appears I need something like scrub_params that turns many associations into [] instead of nil.

The problem is solved. For anyone who happens upon this down the road, the solution I landed on was as follows:

  1. Make sure the has_many line includes on_replace: delete. This means if we PUT to the user controller with a list of phone numbers, any phone numbers in the DB that weren’t included in the list (including their ID!) will be deleted from the DB.
  2. Use scrub_params on the UserController to ensure that any "" values are converted to nil.
  3. In the user changeset, check for nil values for the phone_numbers attr and convert it to an empty list. Technically I could have just done this step matching "" instead of using scrub_params and checking for nil. I’ll have to think through if scrub_params is even valuable in my case.
  4. Made my own version of inputs_for that gave me greater control over the hidden id input, so that I could keep it nested in the same DOM element for convenient deletion via JavaScript.
  5. If all phone numbers are deleted via JavaScript, add a single hidden input as follows, to ensure that we tell Phoenix that we want all of the phone_numbers to be removed.
<input name="user[phone_numbers]" type="hidden" value="">

Here’s the Elixir code.

defmodule App.Accounts.User do
  # ...

  schema "users" do
    # ...
    has_many :phone_numbers, PhoneNumber, on_replace: :delete`
  end

  # ...

  def changeset(%User{} = user, %{"phone_numbers" => nil} = attrs) do
    changeset(user, %{attrs | "phone_numbers" => []})
  end

  def changeset(%User{} = user, attrs) do
    user
    |> Repo.preload(:phone_numbers)
    |> cast(attrs, [:name, :email])
    |> cast_assoc(:phone_numbers, default: [])
    |> validate_required([:name, :email])
  end
end
defmodule App.Web.UserController do
  # ...
  plug :scrub_params, "user", when action in [:update]
  # ...
end

If anyone has a better way to handle this, I’d love to hear it. :slight_smile:

1 Like

Had the same problem, thanks for the solution.
Another thing to mention: I inserted the hidden input to my form by default and it works like a charm. You don’t need to add it manually by javascript in the case that there is no phone_number. It’s only purpose is, to make sure that there is a phone_number list in the changeset.