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.