Edit or Add association in the same form

Hi everyone,
I have this use case where I want to edit an association of an enitity (with a belongs_to relationship) where user has the option to select the value for the associate field from a list or create a new one.

Here is an example of the schemas:

  schema "employee" do
    field :name, :string
    belongs_to :employer, Employer, on_replace: :update

  def changeset(employee, attrs) do
    |> cast(attrs, [ :name ])
    |> cast_assoc(:employer)


  schema "employer" do
    field :name, :string
    has_many :employees, Employee

  def changeset(employer, attrs) do
    |> cast(attrs, [ :name ])

The user should be able to edit the employee and set it’s employer. The UI is supposed to look like this:


My question is basically what is the right way to do this? Since I’m new to the Elixir, I’m lacking the good practice for this case.

My approach so far:

  1. Preloading the Employer
  2. Adding some params for the radio button and the text input which are not in the schema
<.input type="radio" id="existing" field={@form[:existing_or_new]} value="existing" label={...} />
<.inputs_for :let={employer} field={@form[:employer]}>
  <.input field={employer[:id]} type="select" options={@employer_options} />

<.input type="radio" id="new" field={@form[:existing_or_new]} value="new" label={...} />
<.input type="text" field={@form[:new_employer_name]} />

  1. Setting some default values in form assignment function
def assign_form(socket, changeset) do
    base_form = to_form(changeset)

    new_params =
      |> Map.put_new("existing_or_new", "existing")
      |> Map.put_new("new_employer_name", "")

    form = Map.put(base_form, :params, new_params)

    assign(socket, form: form)
  1. And finally update the employee:
def update_employee(%Employee{} = employee, attrs) do
    |> Employee.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:employer, attrs["employer"])
    |> Repo.update()

But the inputs_for adds a _persistent_id which causes the following error:

field names given to change/put_change must be atoms, got: `"_persistent_id"
1 Like

Hmm, inputs_for is usually used with the child schema e.g. a form for an employer would contain inputs_for employee. My guess is that the _persistent_id error you’re seeing is related to the dynamically adding and removing child inputs that inputs_for supports.

This can be tricky since when the parent does not exist, you’d usually want a form that returns something like this where the parent is at the top level e.g. what a form with inputs_for would return:

employer_params = %{
   "name" => "Ericsson",
   "employees" => [%{"name" => "John Doe"}]

|> cast(employer_params, [:name])
|> cast_assoc(:employees)
|> Repo.insert()

Whereas when the parent already exists, you’d want a form that returns something like this where the child is at the top level and a select input dropdown for the parent id.

employee_params = %{"name" => "Eric", "employer_id" => 1}

|> cast(employee_params, [:name, :employer_id])
|> Repo.insert()

Inserting associated records | Ecto Associations guide
Example: Adding a comment to a post | Ecto.Changeset.put_assoc/4 docs

Instead of changing the form struct itself, here’s an example of how you could set an assign that acts as a flag for whether the parent will be existing or new and how to save them together. Note that the code below is for a schema where a calendar has many events instead of an employer has many employees.

# inside the event form, render either a dropdown for the `calendar_id` field or a calendar form based on the boolean assign `@add_to_existing_calendar`
# this flag defaults to `true` and can be toggled by a button also within the event form `@form` via a `phx-click` binding

        <%= if @add_to_existing_calendar do %>
          <.input field={@form[:calendar_id]}
            label="Add to existing calendar:"
            prompt="-- select a calendar for this event --"
        <% else %>
          <.simple_form for={@calendar_form} id="calendar-form">
            <.input field={@calendar_form[:name]}
              label="Add to new calendar:"
        <% end %>
        <p>-- or --</p>
        <.button type="button" phx-click="toggle_calendar_source" phx-target={@myself}>
          <span :if={@add_to_existing_calendar}>Add to new calendar instead</span>
          <span :if={!@add_to_existing_calendar}>Choose existing calendar instead</span>
  def update(%{event: event} = assigns, socket) do
    event_changeset = Scheduling.change_event(event)
    new_calendar_changeset = Scheduling.change_calendar(%Scheduling.Calendar{})
    calendar_options = for cal <- Scheduling.list_calendars, do: {cal.name, cal.id}

     |> assign(assigns)
     |> assign_form(event_changeset)
     |> assign(:add_to_existing_calendar, true)
     |> assign(:calendar_options, calendar_options)
     |> assign(:calendar_form, to_form(new_calendar_changeset))}

  def handle_event("toggle_calendar_source", _, socket) do
    {:noreply, assign(socket, :add_to_existing_calendar, !socket.assigns.add_to_existing_calendar)}

  def handle_event("save", %{"calendar" => calendar_params, "event" => event_params}, socket) do
    # this works thanks to `cast_assoc(:events)` for calendars
    calendar_params_with_event = Map.put(calendar_params, "events", [event_params])
    case Scheduling.create_calendar(calendar_params_with_event) do

  # creating an event tied to an existing calendar via the dropdown works out of the box with the default `"save"` event handler below
  # just make sure `calendar_id` is added to `cast` for events
  def handle_event("save", %{"event" => event_params}, socket) do
    save_event(socket, socket.assigns.action, event_params)

  defp save_event(socket, :edit, event_params) do
    case Scheduling.update_event(socket.assigns.event, event_params) do

Thanks @codeanpeace a lot for the answer and the link, very insightful.

Your approach definitely makes the implementation a lot easier.

Then I got it wrong that the inputs_for can be used on either sides of the relationship. This is not mentioned in documentations (or at least I couldn’t find it). I’ll try an update for documentation to see if I can make it more clear.