Hi to everyone and thank you in advance .
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:
- 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
- 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 %>
- 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?