aswinmohanme

aswinmohanme

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.

Marked As Solved

LostKobrakai

LostKobrakai

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

Last Post!

aswinmohanme

aswinmohanme

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

Where Next?

Popular in Questions Top

vegabook
I’m brand new to Phoenix and I have stripped one of the demo applications to the bone. I just want to get an svg up on the screen. Here i...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New
jason.o
In the code below, if the create action is not set to accept “extra_key” as an input, it errors out with a message shown above. Is there ...
New

Other popular topics Top

electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
grych
Hi folks, Few months ago I have announced the proof-of-concept of the library to manipulate the browsers DOM objects directly from Elixi...
639 54006 488
New
Darmani72
If I have a post route which an argument: post /my_post_route/:my_param1, MyController.my_post_handler How would get the post params ...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New

We're in Beta

About us Mission Statement