Handling Validation Errors in Nested Ecto Changesets in Phoenix LiveView

I’m working with Ecto.Changesets in a Phoenix LiveView form and having trouble displaying validation errors from a nested struct (Company) in the UI.

Context:

I have a user_company_changeset/3 function that creates changesets for both User and Company, and I am manually copying errors from the Company changeset to the User changeset when company_changeset.valid? is false.

Issue:

Without manually adding the inner errors to the user changeset, the validation error inside company is not showing in the UI. Here’s how the changesets look in each case:

:x: Without copying errors (UI does not display nested errors correctly)

#Ecto.Changeset<
  action: :validate,
  changes: %{
    name: "John Rambo",
    password: "**redacted**",
    email: "jrambo@cocacola.com",
    company: #Ecto.Changeset<
      action: :insert,
      changes: %{company_name: "COCACOLA", tenant: "cocaCOLA"},
      errors: [
        tenant: {"must contain only lowercase letters", [validation: :format]}
      ],
      valid?: false,
      ...
    >
  },
  errors: [],  # No top-level errors
  valid?: false
>

:white_check_mark: With manually copying errors (UI displays validation errors properly)

#Ecto.Changeset<
  action: :validate,
  changes: %{
   ...
    company: #Ecto.Changeset<
     ..
      errors: [
        tenant: {"must contain only lowercase letters", [validation: :format]}
      ],
      valid?: false,
      ...
    >
  },
  errors: [
    tenant: {"must contain only lowercase letters", [validation: :format]} # Copied manually
  ],
  valid?: false
>

  • Is manually copying the errors from the Company changeset to the User changeset the best approach?
  • Is there a more idiomatic way in Phoenix/Ecto to ensure nested errors are properly propagated and displayed in the LiveView form?

Reference Code (user_company_changeset/3)

def user_company_changeset(%Company{} = company, %User{} = user, attrs \\ %{}) do
    user = Repo.preload(user, :company)
    
    company_changeset = change_company(company, attrs)
    user_changeset = change_user_registration(user, attrs)
    
    user_changeset =
      if !company_changeset.valid? do
        Enum.reduce(company_changeset.errors, user_changeset, fn {field, {msg, opts}}, changeset ->
          add_error(changeset, field, msg, opts)
        end)
      else
        user_changeset
      end
    
    put_assoc(user_changeset, :company, company_changeset, [])
  end

Any insights would be appreciated!

I don’t think you should need to do anything manually to show the validation forms. But you need to use inputs_for when working with nested forms. I think this assumes that you use cast_assoc in the Changeset to cast the nested assoc. So using LiveView 1.0 with the default core_components your form should look something like this:

<.simple_form
  for={@user_form}
  id="user_form"
  phx-submit="create"
  phx-change="validate"
>
  <.input
    field={@user_form[:name]}
    label="Name"
    type="text"
  />
  <%!-- more user inputs --%>
  <.inputs_for :let={company} field={@form[:company]} skip_hidden={true}>
    <.input
      field={company[:name]}
      label="Company name"
      type="text"
    />
    <%!-- more company inputs --%>
  </.inputs_for>
  <:actions>
    <.button>
      Create
    </.button>
  </:actions>
</.simple_form>

Thanks, works like a charm. As you recommended, I was able to get it working, although I did have to make a slight adjustment to the function. I ended up with the following implementation:

def user_company_changeset(%User{} = user, attrs \\ %{}) do
    user
    |> Repo.preload(:company)
    |> change_user_registration(attrs)
    |> cast_assoc(:company, with: &change_company/2)
    
  end
1 Like