Issues with has_many relations in nested forms

Hi to everyone and thank you in advance :blush:.

My team was working on an feature where we need to create and update some has_many relations through a nestd form. I did an example repo with a simplified version. Basically:

  • We have users.
  • Users can have a variable number of configurable custom fields (kind and value).
  • A user can have N custom fields and just one of each kind.
  • In this example, the custom field available kinds are fixed in a module attribute, but it could be another editable schema.

We want:

  • In the user form, show always a nested a form for each kind of custom value.
  • If you edit an user, load values of custom fields in the form.
  • When save data, avoid to keep empty custom values.
  • If there are any errors, keep data and show error for nested forms.

We think that is a common case, but we are having some problems changesets and forms. Starting with the example, the first problem comes from:

  1. We initialize missing custom fields for new and edit changesets:
    def change_user(%User{} = user) do
      user
      |> initialize_custom_fields()
      |> User.changeset(%{})
    end

    # For new users or not loaded custom fields
    defp initialize_custom_fields(%User{custom_fields: %Ecto.Association.NotLoaded{}} = user) do
      user
      |> Repo.preload(:custom_fields)
      |> initialize_custom_fields()
    end

    defp initialize_custom_fields(%User{custom_fields: custom_fields} = user) do
      Map.put(user, :custom_fields, build_missing_custom_fields(custom_fields))
    end

    # Check a list of custom field values, initializing the missing ones of a custom field kind
    defp build_missing_custom_fields(custom_fields) do
      CustomField.kinds()
      |> Enum.map(fn kind ->
        case Enum.find(custom_fields, &(&1.kind == kind)) do
          nil -> %CustomField{kind: kind}
          custom_field -> custom_field
        end
      end)
    end
  1. We have the nested forms for custom fields:
    <%= inputs_for f, :custom_fields, fn cff -> %>
      <div class="form-group">
        <%= hidden_input cff, :id %>
        <%= hidden_input cff, :kind %>
        <%= label cff, :value, class: "control-label" do %>
          <%= input_value cff, :kind %>
        <% end %>
        <%= text_input cff, :value, class: "form-control" %>
        <%= error_tag cff, :id %>
        <%= error_tag cff, :kind %>
        <%= error_tag cff, :value %>
      </div>
    <% end %>
  1. When the changeset is evaluated, we check for empty values and force the action in order to ignore or delete empty values:
    @doc false
    def changeset(custom_field, attrs) do
      custom_field
      |> cast(attrs, [:value, :kind])
      |> validate_required([:kind])
      |> unique_constraint(:kind, name: :custom_fields_kind_user_id_index)
      |> avoid_empty_values()
    end

    # If there is no id, is a new record
    defp avoid_empty_values(%{valid?: true, data: %{id: nil}} = changeset),
      do: avoid_empty_on_creation(changeset)

    # Else, is an existing record
    defp avoid_empty_values(%{valid?: true} = changeset), do: avoid_empty_on_update(changeset)

    # Any other case or invalid changeset, continue as usual
    defp avoid_empty_values(changeset), do: changeset

    # When create, if value changes to a not empty value, continue
    defp avoid_empty_on_creation(%{changes: %{value: value}} = changeset)
          when value not in [nil, ""],
          do: changeset

    # When create, if it doesn't, ignore changeset
    defp avoid_empty_on_creation(changeset), do: %{changeset | action: :ignore}

    # When update, if value changes ignore changeset
    defp avoid_empty_on_update(%{changes: %{value: value}} = changeset)
          when value in [nil, ""],
          do: %{changeset | action: :delete}

    # When update, if value changes to a not empty value, continue
    defp avoid_empty_on_update(%{changes: %{value: value}} = changeset)
          when value not in [nil, ""],
          do: changeset

    # When update, if it doesn't and the actual record has not value neither, delete record for cleanup
    defp avoid_empty_on_update(%{data: %{value: value}} = changeset)
          when value in [nil, ""],
          do: %{changeset | action: :delete}

    # Any other case or invalid changeset, continue as usual
    defp avoid_empty_on_update(changeset), do: changeset

Everything seems to work, but when you create two custom fields at the same time, the second one always get a [id: {"has already been taken", []}]

#Ecto.Changeset<
  action: nil,
  changes: %{
    custom_fields: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{kind: "Age", value: "1"},
        errors: [],
        data: #TestNested.Accounts.CustomField<>,
        valid?: true
      >,
      #Ecto.Changeset<
        action: :insert,
        changes: %{kind: "Document", value: "2"},
        errors: [id: {"has already been taken", []}],
        data: #TestNested.Accounts.CustomField<>,
        valid?: false
      >
    ]
  },
  errors: [],
  data: #TestNested.Accounts.User<>,
  valid?: false
>

This changeset was inspect just after the |> cast_assoc(:custom_fields) in the user changeset and before the |> Repo.insert().

Why is getting the error before the Repo? Are we missing something with the cast_assoc?

5 Likes

I’ve also had some trouble with this matter in the past and could not find a proper way to do it.

I’ve work around it by created the nested associations in the database when the parent record is created, so they always exist and only their values are updated.
This has worked well for a schema with a small number of associations, but it won’t be a good solution for schemas with lots of associations since it will hurt both the application performance when creating the parent record (since we have to insert many records for its associations) and database (since we have a lot of empty records that wouldn’t be needed until they are actually created with a proper value).

@rsierra Did you get this solved? I’m seeing same issue, not sure if i’m doing this wrong.

OK, I figure this out. I was passing passing extra :id in the form submission. Turns out inputs_for will generate the hidden id input for us, if you look at the HTML source. So just remove line <%= hidden_input cff, :id %> in your code, it should be good then.

2 Likes