How to build simple nested form with Ecto?

Hey,

I just started to build a simple learning app in Phoenix, but I’m struggling to build a simple nested form with Ecto.

My goal is the following: I want to have a user registration form that I can save the following information:

  • First name
  • Last name
  • Occupation (here you’re either a doctor or an attendant)
  • If you’re a doctor, you must also provide a doctor’s registration number

The UI works the following way (simplified):

      <.simple_form
        for={@form}
        id="employee-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
        class="flex flex-col w-full gap-6"
      >
        <section class="flex flex-col gap-4">
          <h2 class="font-semibold">Personal Data</h2>
          <div class="flex flex-col gap-3">
            <.input field={@form[:first_name]} type="text" label="First name" />
            <.input field={@form[:last_name]} type="text" label="Last name" />
          </div>
        </section>

        <section class="flex flex-col gap-4">
          <h2 class="font-semibold">Professional Data</h2>
          <div class="flex flex-col gap-3">
            <.input
              type="select"
              field={@form[:occupation]}
              label="Occupation"
              prompt="Choose an option"
              options={EmployeeForm.occupations()}
            />
            <%= if @employee_form.occupation == :doctor do %>
              <.inputs_for :let={doctor_info} field={@form[:doctor_info]}>
                <.input type="text" field={doctor_info[:registration_id]} label="Registration ID" />
              </.inputs_for>
            <% end %>
          </div>
        </section>

        <:actions>
          <.button phx-disable-with="Saving...">Save</.button>
        </:actions>
      </.simple_form>

In short, I have a select of the occupation of the employee, which based on the choice shows the user an additional form field of the doctor’s registration ID. If the user selects doctor we show the additional input, and if they select attendant we remove the additional field.

To try to solve this, I created an embedded schema, that is going to more closely map what I will be showing on the UI:

defmodule DoctorForm do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :registration_id, :string
  end

  def changeset(%__MODULE__{} = data, attrs) do
    data
    |> cast(attrs, [:id, :registration_id])
    |> validate_required([:registration_id])
    |> validate_length(:registration_id, min: 1, max: 20)
  end

  def from_doctor_info(nil) do
    %__MODULE__{
      id: nil,
      registration_id: ""
    }
  end

  def from_doctor_info(%DoctorInfo{} = doctor_info) do
    %__MODULE__{
      id: doctor_info.id,
      registration_id: doctor_info.registration_id
    }
  end
end

defmodule EmployeeForm do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :first_name, :string
    field :last_name, :string
    field :occupation, values: [:doctor, :attendant]

    embeds_one :doctor_info, DoctorForm, on_replace: :update
  end

  def from_employee(%Employee{} = employee) do
    # Here I have an existing Employee schema that I use to populate
    # the form if I want to update an existing employee. I can pass the result
    # here into the changeset function below.
    %__MODULE__{
      id: employee.id,
      first_name: employee.first_name,
      last_name: employee.last_name,
      occupation: occupation_from_employee(employee),
      doctor_info: DoctorForm.from_doctor_info(employee.doctor_info)
    }
  end

  def changeset(form, attrs \\ %{}) do
    chset =
      form
      |> cast(attrs, [:id, :occupation, :first_name, :last_name])
      |> validate_required([:first_name, :occupation, :last_name])
      |> validate_length(:first_name, min: 1, max: 64)
      |> validate_length(:last_name, min: 1, max: 64)

    chset =
      case dbg(fetch_change(chset, :occupation)) do
        {:ok, :doctor} ->
          dbg(
            put_change(chset, :doctor_info, %{
              :registration_id => ""
            })
          )

        {:ok, :receptionist} ->
          put_change(chset, :doctor_info, nil)

        :error ->
          chset
      end

    chset
  end

  def occupation_from_employee(%Employee{} = employee) do
    case employee.doctor_info do
      nil -> :attendant
      _ -> :doctor
    end
  end
end

Eventually I’m going to convert this form into an employee params dict and save it into the database, but I don’t think this is relevant to the problem, so I omitted the details.

The problem is the EmployeeForm/changeset/2 function. Here I’m trying to do the following logic (without success):

Suppose that I’m calling all EmployeeForm functions from a live view, and then converting EmployeeForm.changeset/2 into a form with to_form.

  1. If I have just changed the occupation on the ui (there’s a select) from :attendant to :doctor, I need to manually include an empty doctor_info: %{registration_id: ""} into the changeset, so that it appears on the UI for the user to fill out.
  2. If I have just changed the occupation on the ui from :doctor to :attendant, I want to remove the existing :doctor_info so that it does not appear on the UI anymore.

However, when I call put_change, it seems like the changes that I want are not added to the changeset.

I have mainly two questions:

  1. Is this the right approach for solving this problem? (it seems this is harder than it should be)
  2. If it is the right approach, can you help me find out where is my mistake?
1 Like

I’d drop the nesting. Have a single (simpler) embedded schema for all the fields of the form and transform to the shape your core business logic expects after the form handling/validation.

6 Likes