Nesting .inputs_for/1 in a dyanmic form, what is the right approach?

I’m trying to get nested form_for attributes in LV (phoenix 1.8 rc 0, liveview 1.0) by following the patterns in `inputs_for/1 but I’m not having any luck.

The associations:

#Job can have many accomplishments, accomplishments can have many sub-accomplishments
defmodule Resume.Jobs.Job do
  use Ecto.Schema
  import Ecto.Changeset

  schema "jobs" do
    field :title, :string
    field :company, :string
    field :start_date, :date
    field :end_date, :date
    has_many :accomplishments, Resume.Accomplishments.Accomplishment, on_replace: :delete

    timestamps(type: :utc_datetime)
  end

  def all_in_one_changeset(job, attrs) do
    job
    |> cast(attrs, [:title, :company, :start_date, :end_date])
    |> validate_required([:title, :company, :start_date, :end_date])
    |> cast_assoc(:accomplishments,
      with: &Resume.Accomplishments.Accomplishment.changeset_from_job/2,
      sort_param: :accomp_sort,
      drop_param: :accomp_drop
    )
  end
end

defmodule Resume.Accomplishments.Accomplishment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "accomplishments" do
    field :name, :string
    field :description, :string
    field :job_id, :id
    has_many :sub_accomplishments, Resume.SubAccomplishments.SubAccomplishment

    timestamps(type: :utc_datetime)
  end

  @doc """
  Used by job when casting with associated accomplishments.
  """
  def changeset_from_job(accomplishment, attrs) do
    accomplishment
    |> cast(attrs, [:name, :description])
    |> validate_required([:name, :description])
    |> cast_assoc(
      :sub_accomplishments,
      with: &Resume.SubAccomplishments.SubAccomplishment.changeset_from_accomplishment/2,
      sort_param: :sub_accomp_sort,
      drop_param: :sub_accomp_drop
    )
  end
end

defmodule Resume.SubAccomplishments.SubAccomplishment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "subaccomplishments" do
    field :name, :string
    field :description, :string
    field :accomplishment_id, :id

    timestamps(type: :utc_datetime)
  end


  @doc """
  Used by accomplishment when casting sub_accomplishment.
  """
  def changeset_from_accomplishment(sub_accomplishment, attrs) do
    sub_accomplishment
    |> cast(attrs, [:name, :description])
    |> validate_required([:name, :description])
  end
end

Following the guide in the docs, I was able to get a dynamic form that allowed adding multiple accomplishments to jobs. My naive approach was to nest the inputs_for sub-accomplishments inside of the inputs_for for accomplishments:

def render(assigns) do
~H"""
      <.form for={@form} id="job-form" phx-change="validate" phx-submit="save">
        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:company]} type="text" label="Company" />
        <.input field={@form[:start_date]} type="date" label="Start date" />
        <.input field={@form[:end_date]} type="date" label="End date" />
        <.inputs_for :let={accomp} field={@form[:accomplishments]}>
          <input type="hidden" name="job[accomp_sort][]" value={accomp.index} />
          <.input type="text" field={accomp[:name]} label="name" />
          <.input type="text" field={accomp[:description]} label="description" />
          <.inputs_for :let={sub_accomp} field={accomp[:sub_accomplishments]}>
            <input type="text" name="accomp-index" value="1" />
            <input type="hidden" name="accomplishments[sub_accomp_sort][]" value={sub_accomp.index} />
            <.input type="text" field={sub_accomp[:name]} label="name" />
            <.input type="text" field={sub_accomp[:description]} label="description" />
            <div class="mt-2 mb-4 w-30">
              <button
                type="button"
                name="accomplishments[sub_accomp_drop][]"
                value={sub_accomp.index}
                class="btn btn-secondary w-full"
                phx-click={JS.dispatch("change")}
              >
                Remove
              </button>
            </div>
          </.inputs_for>
          <input type="hidden" name="accomplishments[sub_accomp_drop][]" />
          <div class="mt-4 mb-6 w-30">
            <button
              type="button"
              name="accomplishments[sub_accomp_sort][]"
              value={"new-#{accomp.index}"}
              class="btn w-full"
              phx-click={JS.dispatch("change")}
              phx-value-accompid="1"
            >
              Add subaccomp
            </button>
          </div>
          <div class="mt-2 mb-4 w-30">
            <button
              type="button"
              name="job[accomp_drop][]"
              value={accomp.index}
              class="btn btn-secondary w-full"
              phx-click={JS.dispatch("change")}
            >
              Remove
            </button>
          </div>
        </.inputs_for>
        <input type="hidden" name="job[accomp_drop][]" />m, to_form(Jobs.change_with_all_children(job)))
  end
        <div class="mt-4 mb-6 w-30">
          <button
            type="button"
            name="job[accomp_sort][]"
            value="new"
            class="btn w-full"
            phx-click={JS.dispatch("change")}
          >
            Add accomp
          </button>
        </div>
        <footer>
          <.button phx-disable-with="Saving..." variant="primary">Save Job</.button>
          <.button navigate={return_path(@current_scope, @return_to, @job)}>Cancel</.button>
        </footer>
      </.form>
end
  @impl true
  def mount(params, _session, socket) do
    {:ok,
     socket
     |> assign(:return_to, return_to(params["return_to"]))m, to_form(Jobs.change_with_all_children(job)))
  end
     |> apply_action(socket.assigns.live_action, params)}
  end


  defp apply_action(socket, :edit, %{"id" => id}) do
    job = Jobs.get_job!(socket.assigns.current_scope, id)

    socket
    |> assign(:page_title, "Edit Job")
    |> assign(:job, job)
    |> assign(:form, to_form(Jobs.change_with_all_children(job)))
  end

  def handle_event("validate", %{"job" => job_params} = full_params, socket) do
    changeset = Jobs.change_with_all_children(socket.assigns.job, job_params)
    {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
  end

"""

What I have noticed is that the sub_accomplishments are sent in the params under a separate “accomplishments” key from the rest of the “job” form. The result is that the sub-accomplishments sections are never rendered.

I’ve been trying to merge the accomplishments parameters into the job parameters which do contain an “accomplishments” key, but keeping the index of the parent accomplishment in sync with the child sub-accomplishments feels like I’m swimming against the current.

Hopefully I’m missing something very obvious.

See the answer here:

1 Like

Thank you! That gave me the info I needed to get things working.

I had a tough time following the linked thread. The marked solution looked like it was telling you to reference the nested form struct outside of the .input_for tag where it was defined. The key for me to understand was to see that we need to provide the full path when providing names for inputs and buttons which requires interpolating the index from the enclosing inputs_for tag.

For anyone coming into this thread in the future here is my working code:

      <.form for={@form} id="job-form" phx-change="validate" phx-submit="save">
        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:company]} type="text" label="Company" />
        <.input field={@form[:start_date]} type="date" label="Start date" />
        <.input field={@form[:end_date]} type="date" label="End date" />
        <h3 class="text-primary-content font-semi-bold mb-2">Accomplishments</h3>
        <.inputs_for :let={accomp} field={@form[:accomplishments]}>
          <input type="hidden" name="job[accomp_sort][]" value={accomp.index} />
          <.input type="text" field={accomp[:name]} label="name" />
          <.input type="text" field={accomp[:description]} label="description" />
          <.inputs_for :let={sub_accomp} field={accomp[:sub_accomplishments]}>
            <input
              type="hidden"
              name={"job[accomplishments][#{accomp.index}][sub_accomp_sort][]"}
              value={sub_accomp.index}
            />
            <.input type="text" field={sub_accomp[:name]} label="name" />
            <.input type="text" field={sub_accomp[:description]} label="description" />
            <div class="mt-2 mb-4 w-30">
              <button
                type="button"
                name={"job[accomplishments][#{sub_accomp.index}][sub_accomp_drop][]"}
                value={sub_accomp.index}
                class="btn btn-secondary w-full"
                phx-click={JS.dispatch("change")}
              >
                Remove
              </button>
            </div>
          </.inputs_for>
          <input type="hidden" name={"job[accomplishments][#{accomp.index}][sub_accomp_drop][]"} />
          <div class="mt-4 mb-6 w-30">
            <button
              type="button"
              name={"job[accomplishments][#{accomp.index}][sub_accomp_sort][]"}
              value="new"
              class="btn w-full"
              phx-click={JS.dispatch("change")}
              phx-value-accompid="1"
            >
              Add subaccomp
            </button>
          </div>
          <div class="mt-2 mb-4 w-30">
            <button
              type="button"
              name="job[accomp_drop][]"
              value={accomp.index}
              class="btn btn-secondary w-full"
              phx-click={JS.dispatch("change")}
            >
              Remove
            </button>
          </div>
        </.inputs_for>
        <input type="hidden" name="job[accomp_drop][]" />
        <div class="mt-4 mb-6 w-30">
          <button
            type="button"
            name="job[accomp_sort][]"
            value="new"
            class="btn w-full"
            phx-click={JS.dispatch("change")}
          >
            Add accomp
          </button>
        </div>
        <footer>
          <.button phx-disable-with="Saving..." variant="primary">Save Job</.button>
          <.button navigate={return_path(@current_scope, @return_to, @job)}>Cancel</.button>
        </footer>
      </.form>