Dynamic nested inputs_for not working when the last element is deleted

I am building a resume builder with the schema

defmodule ModernResume.Resumes.Resume do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "resumes" do
    field :name, :string
    field :email, :string
    field :phone, :string
    field :url, :string
    field :summary, :string

    belongs_to :profile, ModernResume.Profiles.Profile

    has_many :educational_qualifications,
             ModernResume.EducationalQualifications.EducationalQualification,
             preload_order: [desc: :end_date],
             on_replace: :delete

    has_many :work_experiences,
             ModernResume.WorkExperiences.WorkExperience,
             preload_order: [desc: :end_date],
             on_replace: :delete

    has_many :projects,
             ModernResume.Projects.Project,
             on_replace: :delete

    has_many :skills,
             ModernResume.Skills.Skill,
             on_replace: :delete

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(resume, attrs) do
    resume
    |> cast(attrs, [:name, :email, :phone, :url, :summary])
    |> validate_required(:name)
    |> cast_assoc(
      :educational_qualifications,
      with: &ModernResume.EducationalQualifications.EducationalQualification.changeset/2,
      sort_param: :educational_qualifications_order,
      drop_param: :educational_qualifications_delete
    )
    |> cast_assoc(
      :work_experiences,
      with: &ModernResume.WorkExperiences.WorkExperience.changeset/2,
      sort_param: :work_experiences_order,
      drop_param: :work_experiences_delete
    )
    |> cast_assoc(
      :projects,
      with: &ModernResume.Projects.Project.changeset/2,
      sort_param: :projects_order,
      drop_param: :projects_delete
    )
    |> cast_assoc(
      :skills,
      with: &ModernResume.Skills.Skill.changeset/2,
      sort_param: :skills_order,
      drop_param: :skills_delete
    )
  end
end

The Edit LiveView is

defmodule ModernResumeWeb.ResumeLive.Edit do
  use ModernResumeWeb, :live_view

  alias ModernResume.Resumes
  alias ModernResume.TypesettingEngine

  @impl true
  def mount(%{"id" => resume_id}, _session, socket) do
    resume =
      Resumes.get_resume(resume_id)

    changeset =
      Resumes.change_resume(resume)

    {:ok,
     assign(socket,
       resume: resume,
       form: to_form(changeset),
       pdf_base64: nil,
       page_title: resume.name
     )}
  end

  @impl true
  def handle_event("validate", %{"resume" => resume_params}, socket) do
    changeset =
      socket.assigns.resume
      |> Resumes.change_resume(resume_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, form: to_form(changeset))}
  end

  @impl true
  def handle_event("save", %{"resume" => resume_params}, socket) do
    case Resumes.update_resume(
           socket.assigns.resume,
           resume_params
         ) do
      {:ok, resume} ->
        changeset = Resumes.change_resume(resume)

        latex_file_dir =
          TypesettingEngine.to_latex!(resume)
          |> write_latex_file!(resume.id)

        System.cmd("latexmk", ["resume.tex", "-pdf"], cd: latex_file_dir, stderr_to_stdout: true)
        pdf_file_path = Path.join(latex_file_dir, "resume.pdf")
        pdf_base64 = File.read!(pdf_file_path) |> Base.encode64()

        {:noreply,
         assign(socket,
           resume: resume,
           form: to_form(changeset),
           pdf_base64: pdf_base64
         )}

      {:error, changeset} ->
        {:noreply,
         assign(socket,
           form: to_form(changeset)
         )}
    end
  end

  @impl true
  def handle_params(_action, _, socket) do
    header_text =
      if socket.assigns.live_action do
        Phoenix.Naming.humanize(socket.assigns.live_action)
      else
        "Profile"
      end

    {:noreply, socket |> assign(header_text: header_text)}
  end

  def write_latex_file!(content, dirname) do
    tmp_dir = Path.join(System.tmp_dir!(), dirname)
    File.mkdir_p!(tmp_dir)

    tmp_file = Path.join(tmp_dir, "/resume.tex")
    File.write!(tmp_file, content)

    tmp_dir
  end
end

This is the edit.html.heex file

  <div class="min-w-[560px] w-[560px] border-r overflow-y-scroll pb-12">
    <.simple_form id="resume-form" for={@form} phx-submit="save" phx-change="validate">
      <div class="flex flex-row items-center justify-end container-box py-4 border-b">
        <.button class="button-sm">
          <.icon name="hero-document-check" /> Save and Publish
        </.button>
      </div>
      <div class="flex flex-col items-stretch gap-4">
        <div class="container-box flex flex-row items-center justify-between">
          <h2 class="font-medium text-lg text-primary-700"><%= @header_text %></h2>
        </div>
        <div class="form-container container-box py-4 bg-primary-50">
          <.input field={@form[:name]} label="Full name" placeholder="Your name" />
          <.input field={@form[:email]} label="Email" />
          <.input field={@form[:phone]} label="Phone" />
          <.input field={@form[:url]} label="Website / Github / LinkedIn" />
        </div>
      </div>

      <div class="flex flex-col items-stretch gap-4">
        <div class="container-box flex flex-row items-center justify-between">
          <h2 class="font-medium text-lg text-primary-700"><%= @header_text %></h2>
          <label class="button-secondary button-sm">
            <input
              class="hidden"
              type="checkbox"
              name="resume[educational_qualifications_order][]"
            />
            <.icon name="hero-plus-circle" /> Add school
          </label>
        </div>
        <.inputs_for :let={f_eq} field={@form[:educational_qualifications]}>
          <input
            type="hidden"
            name="resume[educational_qualifications_order][]"
            value={f_eq.index}
          />
          <div class="form-container bg-primary-50 container-box py-4">
            <label class="button-outline button-xs self-end">
              <input
                type="checkbox"
                name="resume[educational_qualifications_delete][]"
                value={f_eq.index}
                class="hidden "
              /> <.icon name="hero-x-mark" /> Remove school
            </label>
            <.input field={f_eq[:institution]} label="Institution" placeholder="Your school name" />
            <.input field={f_eq[:location]} label="Location" />
            <.input field={f_eq[:degree]} label="Degree" />
            <.input field={f_eq[:major]} label="Major" />
            <.input field={f_eq[:score]} label="Score / GPA" />
            <div class="flex flex-row gap-4">
              <div>
                <.input type="date" field={f_eq[:start_date]} label="Start date" />
              </div>
              <div>
                <.input
                  type="date"
                  field={f_eq[:end_date]}
                  label="End date (empty if currently studying)"
                />
              </div>
            </div>
          </div>
        </.inputs_for>
      </div>

      <div :if={@live_action == :work_experiences} class="flex flex-col items-stretch gap-4">
        <div class="container-box flex flex-row items-center justify-between">
          <h2 class="font-medium text-lg text-primary-700"><%= @header_text %></h2>
          <label class="button-secondary button-sm">
            <input class="hidden" type="checkbox" name="resume[work_experiences_order][]" />
            <.icon name="hero-plus-circle" /> Add company
          </label>
        </div>
        <.inputs_for :let={f_we} field={@form[:work_experiences]}>
          <input type="hidden" name="resume[work_experiences_order][]" value={f_we.index} />
          <div class="form-container bg-primary-50 container-box py-4">
            <label class="button-outline button-xs self-end">
              <input
                type="checkbox"
                name="resume[work_experiences_delete][]"
                value={f_we.index}
                class="hidden "
              /> <.icon name="hero-x-mark" /> Remove company
            </label>
            <.input field={f_we[:name]} label="Company name" placeholder="Your company name" />
            <.input field={f_we[:location]} label="Location" />
            <.input field={f_we[:position]} label="Position" />
            <.input field={f_we[:url]} label="Company Website" />

            <div class="flex flex-row gap-4">
              <div>
                <.input type="date" field={f_we[:start_date]} label="Start date" />
              </div>
              <div>
                <.input
                  type="date"
                  field={f_we[:end_date]}
                  label="End date (empty if currently working)"
                />
              </div>
            </div>
            <div>
              <div class="mt-8 flex flex-row items-center justify-between mb-2">
                <h3 class="font-medium">Highlights</h3>
                <label class="button-secondary button-xs">
                  <input
                    type="checkbox"
                    class="hidden"
                    name={"resume[work_experiences][#{f_we.index}][highlights_order][]"}
                  />
                  <.icon name="hero-plus-circle" /> Add highlight
                </label>
              </div>
              <.inputs_for :let={f_we_h} field={f_we[:highlights]}>
                <div class="py-4">
                  <div class="flex flex-row justify-end"></div>
                  <input
                    type="hidden"
                    name={"resume[work_experiences][#{f_we.index}][highlights_order][]"}
                    value={f_we_h.index}
                  />
                  <div class="flex flex-row items-start gap-2">
                    <.input field={f_we_h[:description]} />
                    <label class="button-outline button-xs h-10 gap-1">
                      <input
                        type="checkbox"
                        name={"resume[work_experiences][#{f_we.index}][highlights_delete][]"}
                        value={f_we_h.index}
                        class="hidden"
                      /> <.icon name="hero-x-mark" />
                    </label>
                  </div>
                </div>
              </.inputs_for>
            </div>
          </div>
        </.inputs_for>
      </div>

      <div :if={@live_action == :projects} class="flex flex-col items-stretch gap-4">
        <div class="container-box flex flex-row items-center justify-between">
          <h2 class="font-medium text-lg text-primary-700"><%= @header_text %></h2>
          <label class="button-secondary button-sm">
            <input type="checkbox" name="resume[projects_order][]" class="hidden" />
            <.icon name="hero-plus-circle" /> Add project
          </label>
        </div>
        <.inputs_for :let={f_p} field={@form[:projects]}>
          <input type="hidden" name="resume[projects_order][]" value={f_p.index} />
          <div class="form-container bg-primary-50 container-box py-4">
            <label class="button-outline button-xs self-end">
              <input
                type="checkbox"
                name="resume[projects_delete][]"
                value={f_p.index}
                class="hidden"
              /> <.icon name="hero-x-mark" /> Remove project
            </label>
            <.input field={f_p[:name]} label="Name" />
            <.input field={f_p[:description]} label="Description" />
            <.input field={f_p[:url]} label="Link" />
          </div>
        </.inputs_for>
      </div>

      <div :if={@live_action == :skills} class="flex flex-col items-stretch gap-4">
        <div class="container-box flex flex-row items-center justify-between">
          <h2 class="font-medium text-lg text-primary-700"><%= @header_text %></h2>
          <label class="button-secondary button-sm">
            <input type="checkbox" name="resume[skills_order][]" class="hidden" />
            <.icon name="hero-plus-circle" /> Add skill
          </label>
        </div>
        <.inputs_for :let={f_s} field={@form[:skills]}>
          <input type="hidden" name="resume[skills_order][]" value={f_s.index} />
          <div class="form-container bg-primary-50 container-box py-4">
            <label class="button-outline button-xs self-end">
              <input
                type="checkbox"
                name="resume[skills_delete][]"
                value={f_s.index}
                class="hidden"
              /> <.icon name="hero-x-mark" /> Remove skill
            </label>
            <.input field={f_s[:name]} label="Name" />
            <.input field={f_s[:description]} label="Description" />
          </div>
        </.inputs_for>
      </div>

      <:actions></:actions>
    </.simple_form>
  </div>

I’m using the new sort_param and drop_param introduced in the latest ecto update. The issue is everything else works fine, but when I delete the last element of the educational_qualification or work_experience the form gets reset with the association still there. I can add and remove association until it’s the last association.

Here is a video recording https://www.youtube.com/watch?v=8ZqXnKPb0Ac

The validate function processes the changeset correctly, but once the save event is invoked an empty %{} is sent params. The delete params are missing. Any help would be greatly appreciated.

Hello, I may not have understood you correctly, but on a quick look you don’t seem to have disabled the publish button if the changeset is empty?

%{} param comes up when the last item is deleted instead of the educational_qualification_delete => ["0"] param. This is needed by Ecto to delete the associated record.

I haven’t used the sort_param/drop_param method yet but in the examples I’ve seen, “delete” is always a checkbox whereas it looks like you are removing the association from the changeset entirely. This means that you will be sending an empty association which sounds like what you’re describing. However, giving cast_assoc and empty association should delete it. Can you share the error your getting? When you say params is %{} is that all params or just the association?

I am not manually doing anything. The checkbox is supposed to send the delete_param but it doesn’t. It sends an empty map if there is no educational_qualification

I think I do an ugly hack for this… I sanitize params on receive and set to an empty list if not present.

There are probably better ways, but I am used to sanitize on the controller/live/channel side

something like this

params = Map.merge(%{"educational_qualification" => []}, params)

It works, but I am not proud of this code

Seems like you‘re missing the hidden input for the drop field as shown here:

https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#inputs_for/1-dynamically-adding-and-removing-inputs

This fixed the issue, thanks for the pointer. :slightly_smiling_face: