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
.
- If I have just changed the occupation on the ui (there’s a select) from
:attendant
to:doctor
, I need to manually include an emptydoctor_info: %{registration_id: ""}
into the changeset, so that it appears on the UI for the user to fill out. - 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:
- Is this the right approach for solving this problem? (it seems this is harder than it should be)
- If it is the right approach, can you help me find out where is my mistake?