feat(web): edit resumes inline in the Write panel

Write becomes the Basics card, the outline of sections in print order and
Add section. Entries open in place (one at a time, the editor selection),
save as you type and show drafts as "Draft · not printed". Dates use a
structured field with a Present switch and the review note for text that
wasn't read exactly; descriptions use a restricted rich-text toolbar shown
on focus. Sections and entries reorder by drag or Alt+Up/Down, rows carry
the eye, count and every section option, and deleting shows Undo.

Clicking the page opens the entry and scrolls it into view; focusing a field
outlines its block. Phones push an open entry full screen. While a field
has focus the preview waits for a pause in typing.

The left section sidebar, the entry dialogs and the hidden-sections list are
removed.
This commit is contained in:
Amruth Pillai
2026-09-28 18:20:26 +02:00
parent bda01febd5
commit a82741f42a
73 changed files with 3303 additions and 6568 deletions
+3 -3
View File
@@ -414,7 +414,7 @@ const parsedTablesMatchSource = (sourceTables: readonly string[], tables: readon
);
};
const hasUnsupportedTableMarkup = (html: string) => {
export const hasUnsupportedTableMarkup = (html: string) => {
if (typeof DOMParser === "undefined") return false;
const tableMarkers = tableMarkersFrom(html);
if (tableMarkers.length === 0) return false;
@@ -491,7 +491,7 @@ const StyledTableCell = TableCell.extend({
},
});
const extensions = [
export const richInputExtensions = [
StarterKit.configure({
heading: false,
paragraph: false,
@@ -551,7 +551,7 @@ export function RichInput({
const editor = useEditor({
...options,
extensions,
extensions: richInputExtensions,
textDirection,
content: value,
editable: requestedEditable && !hasUnsupportedTable,
-51
View File
@@ -1,21 +1,6 @@
import type { AnyDialogRendererEntry } from "../schemas";
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from ".";
import { ImportResumeDialog } from "./import";
import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award";
import { CreateCertificationDialog, UpdateCertificationDialog } from "./sections/certification";
import { CreateCoverLetterDialog, UpdateCoverLetterDialog } from "./sections/cover-letter";
import { CreateCustomSectionDialog, UpdateCustomSectionDialog } from "./sections/custom";
import { CreateEducationDialog, UpdateEducationDialog } from "./sections/education";
import { CreateExperienceDialog, UpdateExperienceDialog } from "./sections/experience";
import { CreateInterestDialog, UpdateInterestDialog } from "./sections/interest";
import { CreateLanguageDialog, UpdateLanguageDialog } from "./sections/language";
import { CreateProfileDialog, UpdateProfileDialog } from "./sections/profile";
import { CreateProjectDialog, UpdateProjectDialog } from "./sections/project";
import { CreatePublicationDialog, UpdatePublicationDialog } from "./sections/publication";
import { CreateReferenceDialog, UpdateReferenceDialog } from "./sections/reference";
import { CreateSkillDialog, UpdateSkillDialog } from "./sections/skill";
import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./sections/summary-item";
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./sections/volunteer";
import { TemplateGalleryDialog } from "./template/gallery";
export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [
@@ -24,40 +9,4 @@ export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "resume.duplicate", render: ({ data }) => <DuplicateResumeDialog data={data} /> },
{ type: "resume.import", render: () => <ImportResumeDialog /> },
{ type: "resume.template.gallery", render: () => <TemplateGalleryDialog /> },
{ type: "resume.sections.profiles.create", render: ({ data }) => <CreateProfileDialog data={data} /> },
{ type: "resume.sections.profiles.update", render: ({ data }) => <UpdateProfileDialog data={data} /> },
{ type: "resume.sections.experience.create", render: ({ data }) => <CreateExperienceDialog data={data} /> },
{ type: "resume.sections.experience.update", render: ({ data }) => <UpdateExperienceDialog data={data} /> },
{ type: "resume.sections.education.create", render: ({ data }) => <CreateEducationDialog data={data} /> },
{ type: "resume.sections.education.update", render: ({ data }) => <UpdateEducationDialog data={data} /> },
{ type: "resume.sections.skills.create", render: ({ data }) => <CreateSkillDialog data={data} /> },
{ type: "resume.sections.skills.update", render: ({ data }) => <UpdateSkillDialog data={data} /> },
{ type: "resume.sections.projects.create", render: ({ data }) => <CreateProjectDialog data={data} /> },
{ type: "resume.sections.projects.update", render: ({ data }) => <UpdateProjectDialog data={data} /> },
{
type: "resume.sections.certifications.create",
render: ({ data }) => <CreateCertificationDialog data={data} />,
},
{
type: "resume.sections.certifications.update",
render: ({ data }) => <UpdateCertificationDialog data={data} />,
},
{ type: "resume.sections.languages.create", render: ({ data }) => <CreateLanguageDialog data={data} /> },
{ type: "resume.sections.languages.update", render: ({ data }) => <UpdateLanguageDialog data={data} /> },
{ type: "resume.sections.publications.create", render: ({ data }) => <CreatePublicationDialog data={data} /> },
{ type: "resume.sections.publications.update", render: ({ data }) => <UpdatePublicationDialog data={data} /> },
{ type: "resume.sections.awards.create", render: ({ data }) => <CreateAwardDialog data={data} /> },
{ type: "resume.sections.awards.update", render: ({ data }) => <UpdateAwardDialog data={data} /> },
{ type: "resume.sections.interests.create", render: ({ data }) => <CreateInterestDialog data={data} /> },
{ type: "resume.sections.interests.update", render: ({ data }) => <UpdateInterestDialog data={data} /> },
{ type: "resume.sections.volunteer.create", render: ({ data }) => <CreateVolunteerDialog data={data} /> },
{ type: "resume.sections.volunteer.update", render: ({ data }) => <UpdateVolunteerDialog data={data} /> },
{ type: "resume.sections.references.create", render: ({ data }) => <CreateReferenceDialog data={data} /> },
{ type: "resume.sections.references.update", render: ({ data }) => <UpdateReferenceDialog data={data} /> },
{ type: "resume.sections.summary.create", render: ({ data }) => <CreateSummaryItemDialog data={data} /> },
{ type: "resume.sections.summary.update", render: ({ data }) => <UpdateSummaryItemDialog data={data} /> },
{ type: "resume.sections.cover-letter.create", render: ({ data }) => <CreateCoverLetterDialog data={data} /> },
{ type: "resume.sections.cover-letter.update", render: ({ data }) => <UpdateCoverLetterDialog data={data} /> },
{ type: "resume.sections.custom.create", render: ({ data }) => <CreateCustomSectionDialog data={data} /> },
{ type: "resume.sections.custom.update", render: ({ data }) => <UpdateCustomSectionDialog data={data} /> },
];
@@ -1,158 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { awardItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = awardItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
title: "",
awarder: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "awards", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new award</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<AwardForm form={form} />
</SectionItemDialog>
);
}
export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "awards", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing award</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<AwardForm form={form} />
</SectionItemDialog>
);
}
const AwardForm = withForm({
defaultValues,
render: function AwardFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="title">{(field) => <field.TextField label={<Trans>Title</Trans>} />}</form.AppField>
<form.Field name="awarder">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans context="(noun) person, organization, or entity that gives an award">Awarder</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
@@ -1,138 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = certificationItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
title: "",
issuer: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "certifications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new certification</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<CertificationForm form={form} />
</SectionItemDialog>
);
}
export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "certifications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing certification</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<CertificationForm form={form} />
</SectionItemDialog>
);
}
const CertificationForm = withForm({
defaultValues,
render: function CertificationFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="title">{(field) => <field.TextField label={<Trans>Title</Trans>} />}</form.AppField>
<form.AppField name="issuer">{(field) => <field.TextField label={<Trans>Issuer</Trans>} />}</form.AppField>
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
@@ -1,177 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { RichInput } from "@/components/input/rich-input";
import { Combobox } from "@/components/ui/combobox";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { orpc } from "@/libs/orpc/client";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = coverLetterItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
recipient: "",
content: "",
};
export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(value);
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new cover letter</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
singleColumn
>
<ImportFromLibrary
onImport={(letter) => {
form.setFieldValue("recipient", letter.recipient);
form.setFieldValue("content", letter.content);
}}
/>
<CoverLetterForm form={form} />
</SectionItemDialog>
);
}
type ImportFromLibraryProps = {
onImport: (letter: { recipient: string; content: string }) => void;
};
function ImportFromLibrary({ onImport }: ImportFromLibraryProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const letters = useQuery(orpc.coverLetters.list.queryOptions({ input: { limit: 100 } }));
if (!letters.data?.items.length) return null;
return (
<FormItem>
<FormLabel>
<Trans>Import from library</Trans>
</FormLabel>
<FormControl
render={
<Combobox
className="w-full"
value={selectedId}
placeholder={t`Choose a saved cover letter`}
options={letters.data.items.map((letter) => ({ value: letter.id, label: letter.name }))}
onValueChange={(id) => {
setSelectedId(id);
const letter = letters.data.items.find((item) => item.id === id);
if (letter) onImport(letter);
}}
/>
}
/>
</FormItem>
);
}
export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === value.id);
if (index !== -1) section.items[index] = value;
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing cover letter</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
singleColumn
>
<CoverLetterForm form={form} />
</SectionItemDialog>
);
}
const CoverLetterForm = withForm({
defaultValues,
render: ({ form }) => {
return (
<>
<form.Field name="recipient">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Recipient</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="content">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -1,35 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("./custom.tsx", import.meta.url), "utf8");
function rendererBody(name: string) {
const start = source.indexOf(`render: function ${name}({ form }) {`);
expect(start).toBeGreaterThanOrEqual(0);
const end =
name === "CreateCustomSectionFormRenderer" ? source.indexOf("const UpdateCustomSectionForm", start) : source.length;
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}
describe("custom section dialog layout", () => {
it.each(["CreateCustomSectionFormRenderer", "UpdateCustomSectionFormRenderer"])(
"renders icon and title in one row for %s",
(name) => {
const body = rendererBody(name);
const rowIndex = body.indexOf('"flex items-end sm:col-span-full"');
const iconIndex = body.indexOf('name="icon"', rowIndex);
const titleIndex = body.indexOf('name="title"', rowIndex);
const sectionTypeIndex = body.indexOf('name="type"', rowIndex);
expect(rowIndex).toBeGreaterThanOrEqual(0);
expect(iconIndex).toBeGreaterThan(rowIndex);
expect(titleIndex).toBeGreaterThan(iconIndex);
expect(sectionTypeIndex).toBeGreaterThan(titleIndex);
expect(body).toContain('className="rounded-r-none border-input border-e-0"');
expect(body).toContain('className="rounded-s-none"');
},
);
});
@@ -1,366 +0,0 @@
import type { MessageDescriptor } from "@lingui/core";
import type { CustomSectionType } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { customSectionSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { generateId } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { Combobox } from "@/components/ui/combobox";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { defaultSectionIconNames } from "@/libs/resume/section";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
title: "",
type: "experience",
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
};
const SECTION_TYPE_OPTIONS: { value: CustomSectionType; label: MessageDescriptor }[] = [
{ value: "summary", label: msg`Summary` },
{ value: "experience", label: msg`Experience` },
{ value: "education", label: msg`Education` },
{ value: "projects", label: msg`Projects` },
{ value: "profiles", label: msg`Profiles` },
{ value: "skills", label: msg`Skills` },
{ value: "languages", label: msg`Languages` },
{ value: "interests", label: msg`Interests` },
{ value: "awards", label: msg`Awards` },
{ value: "certifications", label: msg`Certifications` },
{ value: "publications", label: msg`Publications` },
{ value: "volunteer", label: msg`Volunteer` },
{ value: "references", label: msg`References` },
{ value: "cover-letter", label: msg`Cover Letter` },
];
function isCustomSectionType(value: string | null | undefined): value is CustomSectionType {
return SECTION_TYPE_OPTIONS.some((option) => option.value === value);
}
function getIconPickerValue(icon: string, type: CustomSectionType): string {
if (icon === "none") return "";
return icon || defaultSectionIconNames[type];
}
export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const initialValues: FormValues = { ...defaultValues, ...data, id: generateId() };
const form = useAppForm({
defaultValues: initialValues,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
draft.customSections.push(value);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
if (lastPageIndex < 0) return;
draft.metadata.layout.pages[lastPageIndex].main.push(value.id);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new custom section</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CreateCustomSectionForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: {
...data,
icon: data.icon ?? "",
},
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === value.id);
if (index === -1) return;
draft.customSections[index] = value;
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing custom section</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<UpdateCustomSectionForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const CreateCustomSectionForm = withForm({
defaultValues,
render: function CreateCustomSectionFormRenderer({ form }) {
const { i18n } = useLingui();
const titleMeta = useStore(form.store, (state) => state.fieldMeta?.title);
const sectionType = useStore(form.store, (state) => state.values.type);
const isTitleInvalid = (titleMeta?.isTouched ?? false) && (titleMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("flex items-end sm:col-span-full", isTitleInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem
className="shrink-0"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<IconPicker
value={getIconPickerValue(field.state.value, sectionType)}
onChange={(icon) => {
field.handleChange(icon === "" ? "none" : icon);
}}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="title">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</div>
<form.Field name="type">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
name={field.name}
value={field.state.value}
disabled={false}
onValueChange={(v) => {
if (isCustomSectionType(v)) field.handleChange(v);
}}
options={SECTION_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: i18n.t(option.label),
}))}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
const UpdateCustomSectionForm = withForm({
defaultValues,
render: function UpdateCustomSectionFormRenderer({ form }) {
const { i18n } = useLingui();
const titleMeta = useStore(form.store, (state) => state.fieldMeta?.title);
const sectionType = useStore(form.store, (state) => state.values.type);
const isTitleInvalid = (titleMeta?.isTouched ?? false) && (titleMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("flex items-end sm:col-span-full", isTitleInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem
className="shrink-0"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<IconPicker
value={getIconPickerValue(field.state.value, sectionType)}
onChange={(icon) => {
field.handleChange(icon === "" ? "none" : icon);
}}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="title">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</div>
<form.Field name="type">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
name={field.name}
value={field.state.value}
disabled
onValueChange={(v) => {
if (isCustomSectionType(v)) field.handleChange(v);
}}
options={SECTION_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: i18n.t(option.label),
}))}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -1,153 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { educationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = educationItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateEducationDialog({ data }: DialogProps<"resume.sections.education.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "education", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new education</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<EducationForm form={form} />
</SectionItemDialog>
);
}
export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.education.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "education", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing education</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<EducationForm form={form} />
</SectionItemDialog>
);
}
const EducationForm = withForm({
defaultValues,
render: function EducationFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="school">{(field) => <field.TextField label={<Trans>School</Trans>} />}</form.AppField>
<form.AppField name="area">{(field) => <field.TextField label={<Trans>Area of Study</Trans>} />}</form.AppField>
<form.AppField name="degree">{(field) => <field.TextField label={<Trans>Degree</Trans>} />}</form.AppField>
<form.AppField name="grade">{(field) => <field.TextField label={<Trans>Grade</Trans>} />}</form.AppField>
<form.AppField name="location">{(field) => <field.TextField label={<Trans>Location</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => (
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
@@ -1,329 +0,0 @@
import type { RoleItem } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon, RowsIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
import { experienceItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { generateId } from "@reactive-resume/utils/string";
import { RichInput } from "@/components/input/rich-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = experienceItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
company: "",
position: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [] as RoleItem[],
};
export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.experience.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "experience", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new experience</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<ExperienceForm form={form} />
</SectionItemDialog>
);
}
export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.experience.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "experience", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing experience</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<ExperienceForm form={form} />
</SectionItemDialog>
);
}
const ExperienceForm = withForm({
defaultValues,
render: function ExperienceFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
const roles = useStore(form.store, (s) => s.values.roles);
const hasRoles = roles.length > 0;
const handleReorderRoles = (newOrder: RoleItem[]) => {
form.setFieldValue("roles", newOrder);
};
return (
<>
<form.AppField name="company">{(field) => <field.TextField label={<Trans>Company</Trans>} />}</form.AppField>
<form.AppField name="location">{(field) => <field.TextField label={<Trans>Location</Trans>} />}</form.AppField>
<form.AppField name="position">{(field) => <field.TextField label={<Trans>Position</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => (
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel>
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
{/* Role Progression */}
<div className="flex items-center justify-between sm:col-span-full">
<div className="space-y-1">
<p className="font-medium text-foreground">
<Trans>Role Progression</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>Add multiple roles to show career progression at the same company.</Trans>
</p>
</div>
<Button
size="sm"
variant="secondary"
className="shrink-0"
onClick={() => {
form.pushFieldValue("roles", {
id: generateId(),
position: "",
period: "",
description: "",
});
}}
>
<PlusIcon />
<Trans>Add Role</Trans>
</Button>
</div>
{hasRoles && (
<form.Field name="roles" mode="array">
{(rolesField) => (
<Reorder.Group
axis="y"
values={rolesField.state.value}
onReorder={handleReorderRoles}
className="flex flex-col gap-4 sm:col-span-full"
>
<AnimatePresence initial={false}>
{rolesField.state.value.map((role: RoleItem, index: number) => (
<RoleFields
key={role.id}
form={form}
role={role}
index={index}
onRemove={() => {
rolesField.removeValue(index);
}}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
</form.Field>
)}
{/* Single Role Description — only show when no roles are defined */}
{!hasRoles && (
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
)}
</>
);
},
});
const RoleFields = withForm({
defaultValues,
props: {
role: {
id: "",
position: "",
period: "",
description: "",
} as RoleItem,
index: 0,
onRemove: () => undefined,
},
render: function RoleFieldsRenderer({ form, role, index, onRemove }) {
const controls = useDragControls();
return (
<Reorder.Item
value={role}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT_STRONG }}
className="relative grid rounded-md border bg-popover sm:col-span-full sm:grid-cols-2"
>
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
<Button
size="sm"
variant="ghost"
className="cursor-grab touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<RowsIcon />
<Trans>Reorder</Trans>
</Button>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={onRemove}>
<TrashSimpleIcon />
<Trans>Remove</Trans>
</Button>
</div>
<div className="grid gap-4 p-4 sm:col-span-full sm:grid-cols-2">
<form.Field name={`roles[${index}].position`}>
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name={`roles[${index}].period`}>
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name={`roles[${index}].description`}>
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</div>
</Reorder.Item>
);
},
});
@@ -1,202 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { interestItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { cn } from "@reactive-resume/utils/style";
import { ChipInput } from "@/components/input/chip-input";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = interestItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
icon: "acorn",
iconColor: "",
name: "",
keywords: [],
};
export function CreateInterestDialog({ data }: DialogProps<"resume.sections.interests.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "interests", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new interest</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<InterestForm form={form} />
</SectionItemDialog>
);
}
export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.interests.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "interests", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing interest</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<InterestForm form={form} />
</SectionItemDialog>
);
}
const InterestForm = withForm({
defaultValues,
render: function InterestFormRenderer({ form }) {
const nameMeta = useStore(form.store, (s) => s.fieldMeta?.name);
const isNameInvalid = (nameMeta?.isTouched ?? false) && (nameMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("col-span-full flex items-end", isNameInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
value={field.state.value}
onChange={(icon: string) => {
field.handleChange(icon);
}}
popoverProps={{ modal: true }}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="name">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none rounded-e-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="iconColor">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<ColorPicker
value={field.state.value}
onChange={(v: string) => {
field.handleChange(v);
}}
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
<form.Field name="keywords">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl
render={
<ChipInput
value={field.state.value}
onChange={(v: string[]) => {
field.handleChange(v);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -1,135 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { languageItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Slider } from "@reactive-resume/ui/components/slider";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = languageItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
language: "",
fluency: "",
level: 0,
};
export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.languages.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "languages", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new language</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<LanguageForm form={form} />
</SectionItemDialog>
);
}
export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.languages.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "languages", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing language</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<LanguageForm form={form} />
</SectionItemDialog>
);
}
const LanguageForm = withForm({
defaultValues,
render: ({ form }) => {
return (
<>
<form.AppField name="language">{(field) => <field.TextField label={<Trans>Language</Trans>} />}</form.AppField>
<form.AppField name="fluency">{(field) => <field.TextField label={<Trans>Fluency</Trans>} />}</form.AppField>
<form.Field name="level">
{(field) => (
<FormItem
className="gap-4 sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[field.state.value]}
onValueChange={(value) => {
field.handleChange(Array.isArray(value) ? value[0] : value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
{Number(field.state.value) === 0 ? t`Hidden` : `${field.state.value} / 5`}
</FormDescription>
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -1,245 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { AtIcon, PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { profileItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = profileItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
icon: "acorn",
iconColor: "",
network: "",
username: "",
website: { url: "", label: "", inlineLink: false },
};
export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profiles.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "profiles", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new profile</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<ProfileForm form={form} />
</SectionItemDialog>
);
}
export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profiles.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "profiles", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing profile</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<ProfileForm form={form} />
</SectionItemDialog>
);
}
const ProfileForm = withForm({
defaultValues,
render: function ProfileFormRenderer({ form }) {
const networkMeta = useStore(form.store, (s) => s.fieldMeta?.network);
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
const isNetworkInvalid = (networkMeta?.isTouched ?? false) && (networkMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("flex items-end", isNetworkInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
value={field.state.value}
onChange={(icon: string) => {
field.handleChange(icon);
}}
popoverProps={{ modal: true }}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="network">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Network</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none rounded-e-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="iconColor">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<ColorPicker
value={field.state.value}
onChange={(v: string) => {
field.handleChange(v);
}}
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
<form.Field name="username">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>
<AtIcon />
</InputGroupText>
</InputGroupAddon>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => (
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -1,141 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { projectItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = projectItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
name: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateProjectDialog({ data }: DialogProps<"resume.sections.projects.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "projects", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new project</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<ProjectForm form={form} />
</SectionItemDialog>
);
}
export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.projects.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "projects", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing project</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<ProjectForm form={form} />
</SectionItemDialog>
);
}
const ProjectForm = withForm({
defaultValues,
render: function ProjectFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="name">{(field) => <field.TextField label={<Trans>Name</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => (
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
@@ -1,140 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { publicationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = publicationItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
title: "",
publisher: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.publications.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "publications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new publication</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<PublicationForm form={form} />
</SectionItemDialog>
);
}
export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.publications.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "publications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing publication</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<PublicationForm form={form} />
</SectionItemDialog>
);
}
const PublicationForm = withForm({
defaultValues,
render: function PublicationFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="title">{(field) => <field.TextField label={<Trans>Title</Trans>} />}</form.AppField>
<form.AppField name="publisher">
{(field) => <field.TextField label={<Trans>Publisher</Trans>} />}
</form.AppField>
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
@@ -1,138 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = referenceItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
name: "",
position: "",
website: { url: "", label: "", inlineLink: false },
phone: "",
description: "",
};
export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.references.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "references", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new reference</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<ReferenceForm form={form} />
</SectionItemDialog>
);
}
export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.references.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "references", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing reference</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<ReferenceForm form={form} />
</SectionItemDialog>
);
}
const ReferenceForm = withForm({
defaultValues,
render: function ReferenceFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="name">{(field) => <field.TextField label={<Trans>Name</Trans>} />}</form.AppField>
<form.AppField name="position">{(field) => <field.TextField label={<Trans>Position</Trans>} />}</form.AppField>
<form.AppField name="phone">{(field) => <field.TextField label={<Trans>Phone</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
@@ -1,76 +0,0 @@
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
type SectionItemDialogProps = {
/** Full dialog title — pass a Trans node so i18n extraction works per-file */
title: ReactNode;
/** Leading icon — PlusIcon (create) or PencilSimpleLineIcon (update) */
icon: ReactNode;
/** Called via `void form.handleSubmit()` from the form's onSubmit handler */
onSubmit: () => void;
/** Called by useFormBlocker's requestClose */
onCancel: () => void;
isSubmitting: boolean;
/** Button label: <Trans>Create</Trans> or <Trans>Save Changes</Trans> */
submitLabel: ReactNode;
/** When true, uses a single-column form layout (cover-letter, summary-item) */
singleColumn?: boolean;
children: ReactNode;
};
/**
* Shared shell for section-item Create/Update dialogs.
* Each dialog still owns its schema, defaultValues, and form hooks.
* This wrapper only removes the duplicated DialogContent/Header/form/Footer boilerplate.
*/
export function SectionItemDialog({
title,
icon,
onSubmit,
onCancel,
isSubmitting,
submitLabel,
singleColumn = false,
children,
}: SectionItemDialogProps) {
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
{icon}
{title}
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className={singleColumn ? "grid gap-4" : "grid gap-4 sm:grid-cols-2"}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
onSubmit();
}}
>
{children}
<DialogFooter className={singleColumn ? undefined : "sm:col-span-full"}>
<Button variant="ghost" onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
@@ -1,240 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { skillItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Slider } from "@reactive-resume/ui/components/slider";
import { cn } from "@reactive-resume/utils/style";
import { ChipInput } from "@/components/input/chip-input";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = skillItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
icon: "acorn",
iconColor: "",
name: "",
proficiency: "",
level: 0,
keywords: [],
};
export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "skills", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new skill</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<SkillForm form={form} />
</SectionItemDialog>
);
}
export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "skills", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing skill</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<SkillForm form={form} />
</SectionItemDialog>
);
}
const SkillForm = withForm({
defaultValues,
render: function SkillFormRenderer({ form }) {
const nameMeta = useStore(form.store, (s) => s.fieldMeta?.name);
const isNameInvalid = (nameMeta?.isTouched ?? false) && (nameMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("flex items-end", isNameInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
value={field.state.value}
onChange={(icon: string) => {
field.handleChange(icon);
}}
popoverProps={{ modal: true }}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="name">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none rounded-e-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="iconColor">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<ColorPicker
value={field.state.value}
onChange={(v: string) => {
field.handleChange(v);
}}
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
<form.AppField name="proficiency">
{(field) => <field.TextField label={<Trans>Proficiency</Trans>} />}
</form.AppField>
<form.Field name="level">
{(field) => (
<FormItem
className="gap-4 sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[field.state.value]}
onValueChange={(value) => {
field.handleChange(Array.isArray(value) ? value[0] : value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
{Number(field.state.value) === 0 ? t`Hidden` : `${field.state.value} / 5`}
</FormDescription>
</FormItem>
)}
</form.Field>
<form.Field name="keywords">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl
render={
<ChipInput
value={field.state.value}
onChange={(v: string[]) => {
field.handleChange(v);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -1,117 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { summaryItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { RichInput } from "@/components/input/rich-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = summaryItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
content: "",
};
export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(value);
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new summary item</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
singleColumn
>
<SummaryItemForm form={form} />
</SectionItemDialog>
);
}
export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeStore = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeStore((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === value.id);
if (index !== -1) section.items[index] = value;
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing summary item</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
singleColumn
>
<SummaryItemForm form={form} />
</SectionItemDialog>
);
}
const SummaryItemForm = withForm({
defaultValues,
render: ({ form }) => {
return (
<form.Field name="content">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
);
},
});
@@ -1,140 +0,0 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = volunteerItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
organization: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "volunteer", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Create a new volunteer experience</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<VolunteerForm form={form} />
</SectionItemDialog>
);
}
export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "volunteer", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<SectionItemDialog
title={<Trans>Update an existing volunteer experience</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<VolunteerForm form={form} />
</SectionItemDialog>
);
}
const VolunteerForm = withForm({
defaultValues,
render: function VolunteerFormRenderer({ form }) {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="organization">
{(field) => <field.TextField label={<Trans>Organization</Trans>} />}
</form.AppField>
<form.AppField name="location">{(field) => <field.TextField label={<Trans>Location</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
});
+1 -34
View File
@@ -1,37 +1,7 @@
import type { CoverLetterItem, CustomSection, SectionItem, SummaryItem } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
type EmptyDialog<T extends string> = { [K in T]: { type: K; data?: undefined } }[T];
type SectionItems = {
profiles: SectionItem<"profiles">;
experience: SectionItem<"experience">;
education: SectionItem<"education">;
projects: SectionItem<"projects">;
skills: SectionItem<"skills">;
languages: SectionItem<"languages">;
interests: SectionItem<"interests">;
awards: SectionItem<"awards">;
certifications: SectionItem<"certifications">;
publications: SectionItem<"publications">;
volunteer: SectionItem<"volunteer">;
references: SectionItem<"references">;
summary: SummaryItem;
"cover-letter": CoverLetterItem;
};
type SectionDialog = {
[T in keyof SectionItems]:
| {
type: `resume.sections.${T}.create`;
data?: { item?: SectionItems[T]; customSectionId?: string };
}
| {
type: `resume.sections.${T}.update`;
data: { item: SectionItems[T]; customSectionId?: string };
};
}[keyof SectionItems];
export type DialogSchema =
| EmptyDialog<
| "auth.change-password"
@@ -49,10 +19,7 @@ export type DialogSchema =
| {
type: "resume.duplicate";
data: { id: string; name: string; slug: string; tags: string[]; shouldRedirect?: boolean };
}
| SectionDialog
| { type: "resume.sections.custom.create"; data?: CustomSection }
| { type: "resume.sections.custom.update"; data: CustomSection };
};
export type DialogType = DialogSchema["type"];
@@ -9,6 +9,7 @@ import { debounce, isEqual } from "es-toolkit";
import { useCallback, useEffect, useState } from "react";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { syncResumeDates } from "@reactive-resume/schema/resume/dates";
import { toast } from "@reactive-resume/ui/components/toast";
import { orpc, streamClient } from "@/libs/orpc/client";
@@ -551,6 +552,9 @@ export const useResumeStore = create<ResumeStore>()(
}
fn(state.resume.data as WritableDraft<ResumeData>);
// The server keeps dates and their legacy text in step on save; doing the same here keeps its
// echo identical to the draft, so an autosave never reads as an outside change.
syncResumeDates(state.resume.data as ResumeData, currentResume.data);
state.saveStatus = "saving";
state.canUndo = state.undoStack.length > 0;
state.canRedo = state.redoStack.length > 0;
@@ -1,229 +0,0 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Resume } from "./draft";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { CustomSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/left/sections/custom";
import { useResumeStore } from "./draft";
import {
focusLeftSidebarSection,
getVisibleLeftSidebarSections,
SectionEditorList,
SectionRecovery,
} from "./section-recovery";
const routerParams = vi.hoisted(() => ({ resumeId: "section-recovery" }));
vi.mock("@tanstack/react-router", () => ({
useParams: () => routerParams,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
getById: { queryOptions: () => ({ queryKey: ["resume", "section-recovery"] }) },
patch: { call: vi.fn() },
update: { call: vi.fn(() => new Promise(() => undefined)) },
},
},
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({
toast: { add: vi.fn(), close: vi.fn() },
}));
function makeResume(data: ResumeData, isLocked = false): Resume {
return {
id: routerParams.resumeId,
name: "Section Recovery",
slug: "section-recovery",
tags: [],
data,
isLocked,
updatedAt: new Date("2026-09-06T00:00:00.000Z"),
};
}
function makeHiddenData(): ResumeData {
const data = structuredClone(sampleResumeData);
data.summary.hidden = true;
data.sections.experience.hidden = true;
data.sections.experience.title = "Work History";
data.customSections[0].hidden = true;
data.customSections[0].title = "Earlier Roles";
return data;
}
function renderRecovery(data = makeHiddenData(), isLocked = false) {
useResumeStore.getState().initialize(makeResume(data, isLocked));
return render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<fieldset disabled={isLocked}>
<SectionRecovery />
</fieldset>
</I18nProvider>
</QueryClientProvider>,
);
}
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
vi.useFakeTimers();
useResumeStore.getState().reset();
});
afterEach(() => {
cleanup();
vi.clearAllTimers();
vi.useRealTimers();
useResumeStore.getState().reset();
});
describe("hidden section recovery", () => {
it("keeps Picture, Basics, and Custom editors while removing hidden printable editors", () => {
const visible = getVisibleLeftSidebarSections(makeHiddenData());
expect(visible).toContain("picture");
expect(visible).toContain("basics");
expect(visible).toContain("custom");
expect(visible).not.toContain("summary");
expect(visible).not.toContain("experience");
});
it("does not mount full editors for hidden printable sections", () => {
useResumeStore.getState().initialize(makeResume(makeHiddenData()));
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<SectionEditorList renderSection={(section) => <div data-testid={`editor-${section}`} />} />
</I18nProvider>
</QueryClientProvider>,
);
expect(screen.getByTestId("editor-picture")).toBeInTheDocument();
expect(screen.getByTestId("editor-basics")).toBeInTheDocument();
expect(screen.getByTestId("editor-custom")).toBeInTheDocument();
expect(screen.queryByTestId("editor-summary")).not.toBeInTheDocument();
expect(screen.queryByTestId("editor-experience")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
});
it("keeps the custom editor container while omitting only hidden custom children", () => {
const data = makeHiddenData();
useResumeStore.getState().initialize(makeResume(data));
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<ConfirmDialogProvider>
<CustomSectionBuilder />
</ConfirmDialogProvider>
</I18nProvider>
</QueryClientProvider>,
);
expect(screen.queryByText("Earlier Roles")).not.toBeInTheDocument();
expect(screen.getAllByText("Cover Letter").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: "Add a new custom section" })).toBeInTheDocument();
});
it("lists hidden built-in, summary, and custom sections by effective title", () => {
renderRecovery();
expect(screen.getByRole("region", { name: "Hidden sections" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Summary section" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Earlier Roles section" })).toBeInTheDocument();
});
it("shows via keyboard, changes only hidden state, and participates in undo and redo", async () => {
const data = makeHiddenData();
const before = structuredClone(data);
vi.useRealTimers();
const user = userEvent.setup();
renderRecovery(data);
const show = screen.getByRole("button", { name: "Show Work History section" });
show.focus();
await user.keyboard("{Enter}");
const shown = useResumeStore.getState().resume?.data;
expect(shown?.sections.experience.hidden).toBe(false);
expect({ ...shown?.sections.experience, hidden: true }).toEqual(before.sections.experience);
expect(shown?.metadata.layout).toEqual(before.metadata.layout);
expect(screen.queryByRole("button", { name: "Show Work History section" })).not.toBeInTheDocument();
act(() => useResumeStore.getState().undo());
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true);
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
act(() => useResumeStore.getState().redo());
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false);
});
it("shows an unplaced hidden section without choosing a layout location", () => {
const data = makeHiddenData();
for (const page of data.metadata.layout.pages) {
page.main = page.main.filter((id) => id !== "experience");
page.sidebar = page.sidebar.filter((id) => id !== "experience");
}
const layoutBefore = structuredClone(data.metadata.layout);
renderRecovery(data);
act(() => screen.getByRole("button", { name: "Show Work History section" }).click());
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false);
expect(useResumeStore.getState().resume?.data.metadata.layout).toEqual(layoutBefore);
});
it("disables recovery actions for a locked resume", () => {
renderRecovery(makeHiddenData(), true);
const show = screen.getByRole("button", { name: "Show Work History section" });
expect(show).toBeDisabled();
show.click();
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true);
expect(useResumeStore.getState().undoStack).toHaveLength(0);
});
it("reopens a collapsed recovery group before focusing and scrolling the hidden section", async () => {
vi.useRealTimers();
const user = userEvent.setup();
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
renderRecovery();
const trigger = screen.getByRole("button", { name: "Hidden sections" });
await user.click(trigger);
await waitFor(() => expect(document.getElementById("sidebar-hidden-experience")).toBeNull());
focusLeftSidebarSection("experience");
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
const recoveryEntry = await waitFor(() => {
const entry = document.getElementById("sidebar-hidden-experience");
expect(entry).not.toBeNull();
return entry;
});
await waitFor(() => expect(recoveryEntry).toHaveFocus());
expect(scrollIntoView).toHaveBeenCalledWith({
block: "start",
inline: "nearest",
behavior: "smooth",
});
});
});
@@ -1,157 +0,0 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import type { LeftSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeClosedIcon, EyeIcon } from "@phosphor-icons/react";
import { Fragment } from "react";
import { getSectionAvailability } from "@reactive-resume/resume/section-availability";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { leftSidebarSections } from "@/libs/resume/section";
import { resolveLayoutSectionTitle } from "@/routes/builder/$resumeId/-sidebar/right/sections/layout/title";
export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSection[] {
const hiddenSectionIds = new Set(
getSectionAvailability(data)
.filter((section) => section.hidden)
.map((section) => section.sectionId),
);
return leftSidebarSections.filter(
(section) =>
section === "picture" || section === "basics" || section === "custom" || !hiddenSectionIds.has(section),
);
}
/** Smooth scrolling unless the user asked for reduced motion. */
export function getScrollBehavior(): ScrollBehavior {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
}
function focusSidebarSection(sectionId: string): void {
const editorTarget = document.getElementById(`sidebar-${sectionId}`);
if (editorTarget) {
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
return;
}
const recoveryTargetId = `sidebar-hidden-${sectionId}`;
const focusRecoveryTarget = () => {
const recoveryTarget = document.getElementById(recoveryTargetId);
if (!recoveryTarget) return;
recoveryTarget.focus({ preventScroll: true });
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
};
const trigger = document.getElementById("sidebar-hidden-sections-trigger");
if (trigger?.getAttribute("aria-expanded") === "false") {
trigger.click();
requestAnimationFrame(focusRecoveryTarget);
return;
}
focusRecoveryTarget();
}
export function focusLeftSidebarSection(section: LeftSidebarSection): void {
focusSidebarSection(section);
}
export function focusCustomSidebarSection(sectionId: string): void {
focusSidebarSection(sectionId);
}
type SectionEditorListProps = {
renderSection: (section: LeftSidebarSection) => ReactNode;
};
export function SectionEditorList({ renderSection }: SectionEditorListProps) {
const sectionKey = useCurrentBuilderResumeSelector((resume) => getVisibleLeftSidebarSections(resume.data).join(","));
const sections = sectionKey.split(",") as LeftSidebarSection[];
return (
<>
{sections.map((section) => (
<Fragment key={section}>
{renderSection(section)}
<Separator />
</Fragment>
))}
<SectionRecovery />
</>
);
}
export function SectionRecovery() {
const data = useCurrentBuilderResumeSelector((resume) => resume.data);
const updateResumeData = useUpdateResumeData();
const hiddenSections = getSectionAvailability(data).filter((section) => section.hidden);
if (hiddenSections.length === 0) return null;
const showSection = (sectionId: string) => {
updateResumeData((draft) => {
if (sectionId === "summary") {
draft.summary.hidden = false;
return;
}
if (Object.hasOwn(draft.sections, sectionId)) {
draft.sections[sectionId as SectionType].hidden = false;
return;
}
const customSection = draft.customSections.find((section) => section.id === sectionId);
if (customSection) customSection.hidden = false;
});
};
return (
<section>
<Accordion defaultValue={["hidden-sections"]}>
<AccordionItem value="hidden-sections" className="rounded-md border px-3">
<AccordionTrigger
id="sidebar-hidden-sections-trigger"
className="items-center no-underline hover:no-underline"
>
<span className="flex items-center gap-x-2">
<EyeClosedIcon aria-hidden="true" />
<Trans>Hidden sections</Trans>
</span>
</AccordionTrigger>
<AccordionContent className="pb-3">
<ul className="space-y-2">
{hiddenSections.map(({ sectionId }) => {
const title = resolveLayoutSectionTitle(data, sectionId);
return (
<li
key={sectionId}
id={`sidebar-hidden-${sectionId}`}
tabIndex={-1}
className="flex items-center justify-between gap-x-3 rounded-md bg-secondary/40 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="min-w-0 truncate font-medium text-sm">{title}</span>
<Button
size="sm"
variant="ghost"
aria-label={t`Show ${title} section`}
onClick={() => showSection(sectionId)}
>
<EyeIcon aria-hidden="true" />
<Trans>Show</Trans>
</Button>
</li>
);
})}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
);
}
@@ -24,6 +24,14 @@ type EditorStore = {
shareOpen: boolean;
downloadOpen: boolean;
assistantOpen: boolean;
/** Write: sections open in the outline. */
openSections: readonly string[];
/** Write: sections added this visit that are still empty (a summary before any text), so they stay listed. */
addedSections: readonly string[];
/** Write: a new draft whose first field takes focus once it renders. */
focusEntryId: string | null;
/** Write: the Basics card. It collapses when an entry is picked on the page. */
basicsOpen: boolean;
select: (selection: EditorSelection | null) => void;
setZoom: (zoom: number | "fit") => void;
setDrawerOpen: (open: boolean) => void;
@@ -31,6 +39,10 @@ type EditorStore = {
setShareOpen: (open: boolean) => void;
setDownloadOpen: (open: boolean) => void;
setAssistantOpen: (open: boolean) => void;
setSectionOpen: (sectionId: string, open: boolean) => void;
markSectionAdded: (sectionId: string) => void;
setFocusEntry: (entryId: string | null) => void;
setBasicsOpen: (open: boolean) => void;
reset: () => void;
};
@@ -54,6 +66,10 @@ const initialState = {
shareOpen: false,
downloadOpen: false,
assistantOpen: false,
openSections: [],
addedSections: [],
focusEntryId: null,
basicsOpen: true,
} as const;
export const useEditorStore = create<EditorStore>()((set) => ({
@@ -65,5 +81,19 @@ export const useEditorStore = create<EditorStore>()((set) => ({
setShareOpen: (shareOpen) => set({ shareOpen }),
setDownloadOpen: (downloadOpen) => set({ downloadOpen }),
setAssistantOpen: (assistantOpen) => set({ assistantOpen }),
setSectionOpen: (sectionId, open) =>
set((state) => {
const isOpen = state.openSections.includes(sectionId);
if (isOpen === open) return state;
return {
openSections: open ? [...state.openSections, sectionId] : state.openSections.filter((id) => id !== sectionId),
};
}),
markSectionAdded: (sectionId) =>
set((state) =>
state.addedSections.includes(sectionId) ? state : { addedSections: [...state.addedSections, sectionId] },
),
setFocusEntry: (focusEntryId) => set({ focusEntryId }),
setBasicsOpen: (basicsOpen) => set({ basicsOpen }),
reset: () => set(initialState),
}));
@@ -0,0 +1,24 @@
import type { WriteSection } from "./model";
import { useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "../store";
import { createEntry, getEntries } from "./model";
/** Opens an entry: expands its section, selects it (outlining it on the page) and focuses a new draft. */
export function openEntry(sectionId: string, entryId: string, options: { focus?: boolean } = {}) {
const editor = useEditorStore.getState();
editor.setSectionOpen(sectionId, true);
editor.select({ kind: "item", sectionId, itemId: entryId });
if (options.focus) editor.setFocusEntry(entryId);
}
/** "+ Add {type}": a draft entry at the end of the section, open and focused. */
export function addEntryTo(section: WriteSection) {
const entry = createEntry(section.type);
useResumeStore.getState().updateResumeData(
(draft) => {
getEntries(draft, section).push(entry);
},
{ newStep: true },
);
openEntry(section.id, entry.id, { focus: true });
}
@@ -0,0 +1,172 @@
import type { CustomSectionType, SectionType } from "@reactive-resume/schema/resume/data";
import type { IconName } from "@reactive-resume/ui/components/icon";
import { Trans } from "@lingui/react/macro";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useCurrentBuilderResumeSelector, useResumeStore } from "@/features/resume/builder/draft";
import { getSectionTitle } from "@/libs/resume/section";
import { useEditorStore } from "../store";
import { openEntry } from "./actions";
import {
addBuiltinSection,
addCustomSection,
BUILTIN_SECTION_TYPES,
CUSTOM_SECTION_TYPES,
isSectionInUse,
} from "./model";
const SECTION_ICONS: Record<CustomSectionType, IconName> = {
summary: "short_text",
experience: "work",
education: "school",
projects: "hub",
skills: "bolt",
languages: "translate",
interests: "bookmark",
awards: "check_circle",
certifications: "fact_check",
publications: "description",
volunteer: "account_circle",
references: "call",
profiles: "link",
"cover-letter": "mail",
};
/** Adds a built-in section (or the summary), opens it and focuses its first field. */
function addSection(type: "summary" | SectionType) {
let entryId: string | null = null;
useResumeStore.getState().updateResumeData(
(draft) => {
entryId = addBuiltinSection(draft, type);
},
{ newStep: true },
);
const editor = useEditorStore.getState();
editor.markSectionAdded(type);
editor.setSectionOpen(type, true);
if (entryId) openEntry(type, entryId, { focus: true });
else requestAnimationFrame(() => document.querySelector<HTMLElement>(`#sidebar-${type} [role="textbox"]`)?.focus());
}
function addCustom(type: CustomSectionType) {
let ids = { sectionId: "", entryId: "" };
useResumeStore.getState().updateResumeData(
(draft) => {
ids = addCustomSection(draft, type, getSectionTitle(type));
},
{ newStep: true },
);
openEntry(ids.sectionId, ids.entryId, { focus: true });
}
/**
* Add section: every section not in use, in two columns, plus Custom section, which asks for a type. The
* new section starts with one draft entry, open and focused.
*/
export function AddSectionMenu() {
const added = useEditorStore((state) => state.addedSections);
const unusedKey = useCurrentBuilderResumeSelector((resume) =>
(["summary", ...BUILTIN_SECTION_TYPES] as const)
.filter((type) => !isSectionInUse(resume.data, type, new Set(added)))
.join(","),
);
const unused = unusedKey ? (unusedKey.split(",") as ("summary" | SectionType)[]) : [];
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className="mt-2 flex h-10 w-full items-center justify-center gap-1.5 rounded-lg border border-line-2 border-dashed text-ink-2 text-sm transition-colors duration-quick hover:border-accent hover:text-accent-text"
>
<Icon name="add" size={18} />
<Trans>Add section</Trans>
</button>
}
/>
<DropdownMenuContent align="start" className="w-[var(--anchor-width)] min-w-[320px]">
{unused.length > 0 && (
<DropdownMenuGroup className="grid grid-cols-2">
{unused.map((type) => (
<DropdownMenuItem key={type} onClick={() => addSection(type)}>
<Icon name={SECTION_ICONS[type]} />
{getSectionTitle(type)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
{unused.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Icon name="add" />
<Trans>Custom section</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans>What goes in it?</Trans>
</DropdownMenuLabel>
{CUSTOM_SECTION_TYPES.map((type) => (
<DropdownMenuItem key={type} onClick={() => addCustom(type)}>
<Icon name={SECTION_ICONS[type]} />
{getSectionTitle(type)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
}
const SUGGESTED: ("summary" | SectionType)[] = ["experience", "education", "skills", "summary"];
/** A blank resume: the first sections to add, as + chips, and an offer to import instead. */
export function StartSuggestions({ onImport }: { onImport: () => void }) {
return (
<div className="grid gap-3 rounded-xl border border-line border-dashed p-4">
<p className="text-ink-2 text-sm">
<Trans>Start with the sections most resumes have.</Trans>
</p>
<div className="flex flex-wrap gap-2">
{SUGGESTED.map((type) => (
<button
key={type}
type="button"
onClick={() => addSection(type)}
className="flex h-8 items-center gap-1 rounded-full border border-line-2 bg-raised px-3 text-sm hover:border-accent hover:text-accent-text"
>
<Icon name="add" size={16} />
{getSectionTitle(type)}
</button>
))}
</div>
<p className="text-ink-3 text-xs">
<Trans>
Have a resume already?{" "}
<button
type="button"
onClick={onImport}
className="font-medium text-accent-text underline underline-offset-2"
>
Import it
</button>
</Trans>
</p>
</div>
);
}
@@ -0,0 +1,239 @@
import type { Basics, CustomField } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { Input } from "@reactive-resume/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { generateId, getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useEditorStore } from "../store";
import { TextField, validateEmail, WebsiteField } from "./fields";
import { PictureSettings } from "./picture-settings";
type TextKey = "name" | "headline" | "email" | "phone" | "location";
function useBasicsWriter() {
const updateResumeData = useUpdateResumeData();
return (key: string, mutate: (basics: Basics) => void) =>
updateResumeData((draft) => mutate(draft.basics), { coalesceKey: `basics.${key}` });
}
/**
* The Basics card: name and "headline · location" with an initials avatar, opening to the photo, contact
* fields and custom fields. The page header selects it.
*/
export function BasicsCard({ locked }: { locked: boolean }) {
const basics = useCurrentBuilderResumeSelector((resume) => resume.data.basics);
const open = useEditorStore((state) => state.basicsOpen);
const setOpen = useEditorStore((state) => state.setBasicsOpen);
const selected = useEditorStore((state) => state.selection?.kind === "header");
const write = useBasicsWriter();
const text = (key: TextKey) => ({
value: basics[key],
onChange: (value: string) =>
write(key, (target) => {
target[key] = value;
}),
});
return (
<section
id="sidebar-basics"
aria-label={t`Basics`}
className={cn(
"scroll-mt-[60px] rounded-xl border border-line bg-surface transition-[border-color] duration-quick",
selected && "border-accent",
)}
>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-3 rounded-xl p-3 text-start"
>
<span
aria-hidden="true"
className="grid size-10 shrink-0 place-items-center rounded-full bg-accent-soft font-semibold text-accent-text text-sm"
>
{getInitials(basics.name || "?")}
</span>
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-semibold text-sm", !basics.name && "text-ink-3")}>
{basics.name || <Trans>Your name</Trans>}
</span>
<span className="block truncate text-ink-3 text-xs">
{[basics.headline, basics.location].filter((part) => part.trim()).join(" · ")}
</span>
</span>
<Icon
name="expand_more"
className={cn("text-ink-2 transition-transform duration-standard", open && "rotate-180")}
/>
</button>
{open && (
<fieldset
disabled={locked}
className="m-0 grid min-w-0 grid-cols-2 gap-x-3 gap-y-2.5 border-0 border-line border-t px-3 pt-3 pb-3.5"
>
<PhotoRow locked={locked} />
<TextField
label={<Trans>Full name</Trans>}
wide
autoComplete="name"
// A blank resume opens straight into the name field.
autoFocus={!locked && !basics.name}
{...text("name")}
/>
<TextField label={<Trans>Headline</Trans>} wide {...text("headline")} />
<TextField
label={<Trans>Email</Trans>}
type="email"
autoComplete="email"
validate={validateEmail}
{...text("email")}
/>
<TextField label={<Trans>Phone</Trans>} type="tel" autoComplete="tel" {...text("phone")} />
<TextField label={<Trans>Location</Trans>} wide {...text("location")} />
<WebsiteField
label={<Trans>Website</Trans>}
value={basics.website}
allowInlineLink={false}
onChange={(website) =>
write("website", (target) => {
target.website = { url: website.url, label: website.label };
})
}
/>
<CustomFields fields={basics.customFields} />
</fieldset>
)}
</section>
);
}
function PhotoRow({ locked }: { locked: boolean }) {
const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture);
const hasPhoto = Boolean(picture.url);
return (
<div id="sidebar-picture" className="col-span-full flex items-center gap-3 rounded-lg bg-bg p-2.5">
{hasPhoto ? (
<img src={picture.url} alt="" className="size-10 shrink-0 rounded-md object-cover" />
) : (
<span className="grid size-10 shrink-0 place-items-center rounded-md bg-sunken text-ink-3">
<Icon name="add_a_photo" />
</span>
)}
<p className="min-w-0 flex-1 text-ink-2 text-xs leading-4">
{!hasPhoto ? (
<Trans>No photo. Most ATS ignore photos; some regions expect one.</Trans>
) : picture.hidden ? (
<Trans>Photo hidden from the page.</Trans>
) : (
<Trans>Photo on the page.</Trans>
)}
</p>
<Popover>
<PopoverTrigger
render={
<Button variant="secondary" size="sm" disabled={locked}>
{hasPhoto ? <Trans>Edit</Trans> : <Trans>Add</Trans>}
</Button>
}
/>
<PopoverContent align="end" className="max-h-[70svh] w-[360px] overflow-y-auto p-4">
<PictureSettings />
</PopoverContent>
</Popover>
</div>
);
}
/** Extra contact details (icon, text and an optional link), as "Add field" under Website. */
function CustomFields({ fields }: { fields: CustomField[] }) {
const updateResumeData = useUpdateResumeData();
const edit = (key: string, mutate: (list: CustomField[]) => void) =>
updateResumeData((draft) => mutate(draft.basics.customFields), { coalesceKey: `basics.customFields.${key}` });
return (
<div className="col-span-full grid gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center gap-1.5">
<IconPicker
value={field.icon}
popoverProps={{ modal: true }}
onChange={(icon) =>
edit(`${field.id}.icon`, (list) => {
const target = list[index];
if (target) target.icon = icon;
})
}
/>
<Input
aria-label={t`Field ${index + 1}`}
value={field.text}
onChange={(event) =>
edit(`${field.id}.text`, (list) => {
const target = list[index];
if (target) target.text = event.target.value;
})
}
/>
<Popover>
<PopoverTrigger
render={
<IconButton
icon={field.link ? "link" : "link_off"}
label={field.link ? t`Edit link` : t`Add a link`}
className={field.link ? "text-accent-text" : "text-ink-3"}
/>
}
/>
<PopoverContent align="end" className="w-72">
<Input
type="url"
aria-label={t`Link address`}
placeholder="https://"
value={field.link}
onChange={(event) =>
edit(`${field.id}.link`, (list) => {
const target = list[index];
if (target) target.link = event.target.value;
})
}
/>
</PopoverContent>
</Popover>
<IconButton
icon="arrow_upward"
label={t`Move field up`}
size="icon-sm"
disabled={index === 0}
onClick={() => edit(`${field.id}.move`, (list) => list.splice(index - 1, 0, ...list.splice(index, 1)))}
/>
<IconButton
icon="close"
label={t`Remove field`}
size="icon-sm"
onClick={() => edit(`${field.id}.remove`, (list) => list.splice(index, 1))}
/>
</div>
))}
<button
type="button"
className="flex h-9 w-fit items-center gap-1.5 rounded-lg px-2 text-accent-text text-sm hover:bg-hover"
onClick={() => edit("add", (list) => list.push({ id: generateId(), icon: "acorn", text: "", link: "" }))}
>
<Icon name="add" size={18} />
<Trans>Add field</Trans>
</button>
</div>
);
}
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import type { ResumeDates } from "@reactive-resume/schema/resume/dates";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { DatesField, readTypedDate } from "./dates-field";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en-US", messages: {} });
});
describe("readTypedDate", () => {
it.each([
["Mar 2022", "2022-03"],
["03/2022", "2022-03"],
["2022-03", "2022-03"],
["2022", "2022"],
["", null],
["soon", undefined],
])("reads %j as %j", (text, expected) => {
expect(readTypedDate(text, "en-US")).toBe(expected);
});
});
describe("DatesField", () => {
const renderField = (dates: ResumeDates, onChange = vi.fn()) => {
render(
<I18nProvider i18n={i18n}>
<DatesField dates={dates} locale="en-US" format="short" onChange={onChange} />
</I18nProvider>,
);
return onChange;
};
it("saves a typed date and clears the review note", () => {
const onChange = renderField({ start: "2016-06", end: "2018", present: false, raw: "Summer 2016 - 2018" });
expect(screen.getByText(/We read "Summer 2016 - 2018"/)).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Start" }), { target: { value: "Jul 2016" } });
expect(onChange).toHaveBeenLastCalledWith({ start: "2016-07", end: "2018", present: false });
});
it("turns the end into Present and back", () => {
const onChange = renderField({ start: "2020", end: "2022", present: false });
fireEvent.click(screen.getByRole("switch"));
expect(onChange).toHaveBeenLastCalledWith({ start: "2020", end: null, present: true });
});
it("says how to fix a date it can't read, after blur", () => {
renderField({ start: null, end: null, present: false });
const start = screen.getByRole("textbox", { name: "Start" });
fireEvent.change(start, { target: { value: "someday" } });
expect(screen.queryByText(/Use a month and year/)).not.toBeInTheDocument();
fireEvent.blur(start);
expect(screen.getByText(/Use a month and year/)).toBeInTheDocument();
});
});
@@ -0,0 +1,173 @@
import type { DateFormat, ResumeDates, YearMonth } from "@reactive-resume/schema/resume/dates";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useId, useState } from "react";
import { formatYearMonth, getPresentLabel, toYearMonth } from "@reactive-resume/schema/resume/dates";
import { readSingleDate } from "@reactive-resume/schema/resume/period";
import { Icon } from "@reactive-resume/ui/components/icon";
import { inputBaseClassName } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
type DateSettings = { locale: string; format?: DateFormat | undefined };
/** What someone typed as a date: a year-month, null when cleared, or undefined when it can't be read. */
export function readTypedDate(text: string, locale: string): YearMonth | null | undefined {
const value = text.trim();
if (!value) return null;
const reading = readSingleDate(value, locale);
return reading ? toYearMonth(reading.endpoint) : undefined;
}
const isBefore = (end: YearMonth, start: YearMonth) => end.localeCompare(start) < 0 && !start.startsWith(end);
type MonthYearInputProps = DateSettings & {
label: string;
value: YearMonth | null;
disabled?: boolean;
/** Shown instead of the value, e.g. "Present" while the entry is ongoing. */
placeholderValue?: string;
onCommit: (value: YearMonth | null) => void;
onInvalid: (invalid: boolean) => void;
};
/**
* One date, typed the way people write it ("Mar 2022", "03/2022", "2022-03" or just "2022"). A readable value
* saves as you type; on blur the field shows it in the resume's date format.
*/
function MonthYearInput({
label,
value,
locale,
format,
disabled,
placeholderValue,
onCommit,
onInvalid,
}: MonthYearInputProps) {
const [typed, setTyped] = useState<string | null>(null);
const formatted = value ? formatYearMonth(value, { locale, format }) : "";
return (
<div className="relative w-[132px]">
<Icon
name="calendar_month"
size={18}
className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-ink-3"
/>
<input
type="text"
inputMode="text"
autoComplete="off"
aria-label={label}
disabled={disabled}
placeholder={t`Mar 2022`}
value={placeholderValue ?? typed ?? formatted}
className={cn(inputBaseClassName, "h-9 ps-9 disabled:bg-sunken disabled:text-ink-2 disabled:opacity-100")}
onChange={(event) => {
setTyped(event.target.value);
const next = readTypedDate(event.target.value, locale);
if (next !== undefined && next !== value) onCommit(next);
}}
onBlur={() => {
if (typed === null) return;
const next = readTypedDate(typed, locale);
onInvalid(next === undefined);
if (next !== undefined) setTyped(null);
}}
/>
</div>
);
}
type DatesFieldProps = DateSettings & {
dates: ResumeDates;
/** Awards, certifications and publications have one date. */
single?: boolean;
onChange: (dates: ResumeDates) => void;
className?: string;
};
/**
* Structured dates: start – end with a Present switch, or a single date. Editing any part clears the
* "needs a look" note left by text that couldn't be read exactly.
*/
export function DatesField({ dates, single = false, locale, format, onChange, className }: DatesFieldProps) {
const id = useId();
const [invalid, setInvalid] = useState<Record<"start" | "end", boolean>>({ start: false, end: false });
// Any edit replaces the dates without `raw`, which clears the review note.
const current: ResumeDates = { start: dates.start, end: dates.end, present: dates.present };
const reversed = !single && !dates.present && dates.start && dates.end && isBefore(dates.end, dates.start);
const hasError = invalid.start || invalid.end;
const setInvalidFor = (part: "start" | "end") => (value: boolean) =>
setInvalid((state) => (state[part] === value ? state : { ...state, [part]: value }));
return (
<fieldset className={cn("grid min-w-0 gap-1.5", className)} aria-describedby={`${id}-note`}>
<legend className="mb-1.5 font-medium text-[13px] text-ink leading-4">
{single ? <Trans>Date</Trans> : <Trans>Dates</Trans>}
</legend>
<div className="flex flex-wrap items-center gap-2">
<MonthYearInput
label={single ? t`Date` : t`Start`}
value={dates.start}
locale={locale}
format={format}
onCommit={(start) => onChange({ ...current, start })}
onInvalid={setInvalidFor("start")}
/>
{!single && (
<>
<span aria-hidden="true" className="text-ink-3">
–
</span>
<MonthYearInput
label={t`End`}
value={dates.end}
locale={locale}
format={format}
disabled={dates.present}
placeholderValue={dates.present ? getPresentLabel(locale) : undefined}
onCommit={(end) => onChange({ ...current, end })}
onInvalid={setInvalidFor("end")}
/>
{/* biome-ignore lint/a11y/noLabelWithoutControl: Base UI's Switch is the control; wrapping it in a label is its documented pattern. */}
<label className="flex cursor-pointer items-center gap-2 p-1 text-sm">
<Switch
checked={dates.present}
onCheckedChange={(present) => onChange({ ...current, present, end: present ? null : dates.end })}
/>
<Trans>Present</Trans>
</label>
</>
)}
</div>
<div id={`${id}-note`}>
{hasError ? (
<p className="flex items-start gap-1 text-danger-text text-xs leading-4">
<Icon name="error" size={16} className="shrink-0" />
<Trans>Use a month and year, like Mar 2022, or just a year.</Trans>
</p>
) : dates.raw !== undefined ? (
<p className="flex items-start gap-1 text-warn-text text-xs leading-4">
<Icon name="warning" size={16} className="shrink-0" />
{dates.start ? (
<Trans>We read "{dates.raw}". Pick a month so it sorts and prints consistently.</Trans>
) : (
<Trans>We couldn't read "{dates.raw}". Enter the dates so they sort and print consistently.</Trans>
)}
</p>
) : reversed ? (
<p className="flex items-start gap-1 text-warn-text text-xs leading-4">
<Icon name="warning" size={16} className="shrink-0" />
<Trans>The end is before the start.</Trans>
</p>
) : null}
</div>
</fieldset>
);
}
@@ -0,0 +1,452 @@
import type { CustomSectionType, RoleItem, Website } from "@reactive-resume/schema/resume/data";
import type { DateFormat, ResumeDates } from "@reactive-resume/schema/resume/dates";
import type { ReactNode } from "react";
import type { EntryWriter } from "./fields";
import type { Entry } from "./model";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EMPTY_RESUME_DATES } from "@reactive-resume/schema/resume/dates";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormDescription, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Slider } from "@reactive-resume/ui/components/slider";
import { generateId } from "@reactive-resume/utils/string";
import { ChipInput } from "@/components/input/chip-input";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { DatesField } from "./dates-field";
import { MoreOptions, TextField, WebsiteField } from "./fields";
import { RichTextEditor } from "./rich-text-editor";
export type PageSettings = { locale: string; dateFormat?: DateFormat | undefined };
type FieldSetProps = { entry: Entry; write: EntryWriter; page: PageSettings };
type Values = Record<string, unknown> & {
website?: Website & { inlineLink?: boolean };
dates?: ResumeDates;
keywords?: string[];
roles?: RoleItem[];
level?: number;
};
const valuesOf = (entry: Entry) => entry as unknown as Values;
const str = (value: unknown) => (typeof value === "string" ? value : "");
/** A text field bound to one entry field. */
function Text({
entry,
write,
field,
label,
wide,
autoFocus,
}: FieldSetProps & { field: string; label: ReactNode; wide?: boolean; autoFocus?: boolean }) {
return (
<TextField
label={label}
value={str(valuesOf(entry)[field])}
wide={wide}
autoFocus={autoFocus}
data-entry-field={field}
onChange={(value) =>
write(field, (target) => {
target[field] = value;
})
}
/>
);
}
function Dates({ entry, write, page, single }: FieldSetProps & { single?: boolean }) {
return (
<DatesField
className="col-span-full"
dates={valuesOf(entry).dates ?? EMPTY_RESUME_DATES}
single={single}
locale={page.locale}
format={page.dateFormat}
onChange={(dates) =>
write("dates", (target) => {
target.dates = dates;
})
}
/>
);
}
function Link({ entry, write }: FieldSetProps) {
const website = valuesOf(entry).website ?? { url: "", label: "", inlineLink: false };
return (
<WebsiteField
value={website}
onChange={(value) =>
write("website", (target) => {
target.website = value;
})
}
/>
);
}
function Description({
entry,
write,
field = "description",
label,
}: FieldSetProps & { field?: string; label?: string }) {
return (
<div className="col-span-full grid gap-1.5">
<span className="font-medium text-[13px] leading-4">{label ?? t`Description`}</span>
<RichTextEditor
label={label ?? t`Description`}
value={str(valuesOf(entry)[field])}
onChange={(html) =>
write(field, (target) => {
target[field] = html;
})
}
/>
</div>
);
}
function Keywords({ entry, write, label }: FieldSetProps & { label: ReactNode }) {
return (
<FormItem className="col-span-full">
<FormLabel>{label}</FormLabel>
<FormControl
render={
<ChipInput
value={valuesOf(entry).keywords ?? []}
onChange={(keywords) =>
write("keywords", (target) => {
target.keywords = keywords;
})
}
/>
}
/>
</FormItem>
);
}
function Level({ entry, write }: FieldSetProps) {
const level = Number(valuesOf(entry).level ?? 0);
return (
<FormItem className="col-span-full gap-3">
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[level]}
onValueChange={(value) =>
write("level", (target) => {
target.level = Array.isArray(value) ? value[0] : value;
})
}
/>
}
/>
<FormDescription>{level === 0 ? t`Hidden` : `${level} / 5`}</FormDescription>
</FormItem>
);
}
/** The icon printed before the entry, and its colour (blank uses the template's). */
function IconAndColor({ entry, write }: FieldSetProps) {
const values = valuesOf(entry);
return (
<div className="col-span-full flex items-center gap-2">
<IconPicker
value={str(values.icon)}
onChange={(icon: string) =>
write("icon", (target) => {
target.icon = icon;
})
}
popoverProps={{ modal: true }}
/>
<ColorPicker
value={str(values.iconColor)}
onChange={(color) =>
write("iconColor", (target) => {
target.iconColor = color;
})
}
trigger={
<PopoverTrigger
aria-label={t`Icon colour`}
className="flex h-9 items-center gap-2 rounded-lg border border-line-2 bg-raised px-2.5 text-sm"
>
<span
className="size-4 rounded-full border border-line-2"
style={{ backgroundColor: str(values.iconColor) || "currentColor" }}
/>
<Trans>Icon colour</Trans>
</PopoverTrigger>
}
/>
</div>
);
}
function Roles({ entry, write, page }: FieldSetProps) {
const roles = valuesOf(entry).roles ?? [];
const writeRoles = (key: string, mutate: (roles: RoleItem[]) => void) =>
write(`roles:${key}`, (target) => {
mutate(target.roles as RoleItem[]);
});
return (
<div className="col-span-full grid gap-3">
{roles.map((role, index) => (
<div key={role.id} className="grid grid-cols-2 gap-x-3 gap-y-2.5 rounded-lg border border-line bg-bg p-3">
<div className="col-span-full flex items-center justify-between">
<span className="font-medium text-[13px] text-ink-2">
<Trans>Role {index + 1}</Trans>
</span>
<div className="flex">
<IconButton
icon="arrow_upward"
label={t`Move role up`}
size="icon-sm"
disabled={index === 0}
onClick={() =>
writeRoles(`move-${role.id}`, (list) => list.splice(index - 1, 0, ...list.splice(index, 1)))
}
/>
<IconButton
icon="arrow_downward"
label={t`Move role down`}
size="icon-sm"
disabled={index === roles.length - 1}
onClick={() =>
writeRoles(`move-${role.id}`, (list) => list.splice(index + 1, 0, ...list.splice(index, 1)))
}
/>
<IconButton
icon="delete"
label={t`Remove role`}
size="icon-sm"
onClick={() => writeRoles(`remove-${role.id}`, (list) => list.splice(index, 1))}
/>
</div>
</div>
<TextField
label={<Trans>Position</Trans>}
value={role.position}
wide
onChange={(value) =>
writeRoles(`${role.id}:position`, (list) => {
const target = list[index];
if (target) target.position = value;
})
}
/>
<DatesField
className="col-span-full"
dates={role.dates ?? EMPTY_RESUME_DATES}
locale={page.locale}
format={page.dateFormat}
onChange={(dates) =>
writeRoles(`${role.id}:dates`, (list) => {
const target = list[index];
if (target) target.dates = dates;
})
}
/>
<div className="col-span-full grid gap-1.5">
<span className="font-medium text-[13px] leading-4">
<Trans>Description</Trans>
</span>
<RichTextEditor
label={t`Description of role ${index + 1}`}
value={role.description}
onChange={(html) =>
writeRoles(`${role.id}:description`, (list) => {
const target = list[index];
if (target) target.description = html;
})
}
/>
</div>
</div>
))}
<Button
variant="secondary"
size="sm"
className="w-fit"
onClick={() =>
writeRoles("add", (list) => {
list.push({
id: generateId(),
position: "",
period: "",
dates: { ...EMPTY_RESUME_DATES },
description: "",
});
})
}
>
<Trans>Add role</Trans>
</Button>
</div>
);
}
/** The fields each entry type edits, in a two-column grid. The first field takes focus on a new draft. */
export function EntryFields({
type,
autoFocus,
...props
}: FieldSetProps & { type: CustomSectionType; autoFocus?: boolean }) {
const hasRoles = (valuesOf(props.entry).roles?.length ?? 0) > 0;
switch (type) {
case "experience":
return (
<>
<Text {...props} field="position" label={<Trans>Position</Trans>} autoFocus={autoFocus} />
<Text {...props} field="company" label={<Trans>Company</Trans>} />
<Text {...props} field="location" label={<Trans>Location</Trans>} wide />
<Dates {...props} />
<Link {...props} />
{!hasRoles && <Description {...props} />}
<Roles {...props} />
</>
);
case "education":
return (
<>
<Text {...props} field="school" label={<Trans>School</Trans>} wide autoFocus={autoFocus} />
<Text {...props} field="degree" label={<Trans>Degree</Trans>} />
<Text {...props} field="area" label={<Trans>Area of study</Trans>} />
<Text {...props} field="grade" label={<Trans>Grade</Trans>} />
<Text {...props} field="location" label={<Trans>Location</Trans>} />
<Dates {...props} />
<Link {...props} />
<Description {...props} />
</>
);
case "projects":
return (
<>
<Text {...props} field="name" label={<Trans>Name</Trans>} wide autoFocus={autoFocus} />
<Dates {...props} />
<Link {...props} />
<Description {...props} />
</>
);
case "skills":
return (
<>
<Text {...props} field="name" label={<Trans>Name</Trans>} autoFocus={autoFocus} />
<Text {...props} field="proficiency" label={<Trans>Proficiency</Trans>} />
<Keywords {...props} label={<Trans>Keywords</Trans>} />
<MoreOptions>
<Level {...props} />
<IconAndColor {...props} />
</MoreOptions>
</>
);
case "languages":
return (
<>
<Text {...props} field="language" label={<Trans>Language</Trans>} autoFocus={autoFocus} />
<Text {...props} field="fluency" label={<Trans>Fluency</Trans>} />
<MoreOptions>
<Level {...props} />
</MoreOptions>
</>
);
case "interests":
return (
<>
<Text {...props} field="name" label={<Trans>Name</Trans>} wide autoFocus={autoFocus} />
<Keywords {...props} label={<Trans>Keywords</Trans>} />
<MoreOptions>
<IconAndColor {...props} />
</MoreOptions>
</>
);
case "awards":
return (
<>
<Text {...props} field="title" label={<Trans>Title</Trans>} autoFocus={autoFocus} />
<Text {...props} field="awarder" label={<Trans>Awarder</Trans>} />
<Dates {...props} single />
<Link {...props} />
<Description {...props} />
</>
);
case "certifications":
return (
<>
<Text {...props} field="title" label={<Trans>Title</Trans>} autoFocus={autoFocus} />
<Text {...props} field="issuer" label={<Trans>Issuer</Trans>} />
<Dates {...props} single />
<Link {...props} />
<Description {...props} />
</>
);
case "publications":
return (
<>
<Text {...props} field="title" label={<Trans>Title</Trans>} autoFocus={autoFocus} />
<Text {...props} field="publisher" label={<Trans>Publisher</Trans>} />
<Dates {...props} single />
<Link {...props} />
<Description {...props} />
</>
);
case "volunteer":
return (
<>
<Text {...props} field="organization" label={<Trans>Organization</Trans>} autoFocus={autoFocus} />
<Text {...props} field="location" label={<Trans>Location</Trans>} />
<Dates {...props} />
<Link {...props} />
<Description {...props} />
</>
);
case "references":
return (
<>
<Text {...props} field="name" label={<Trans>Name</Trans>} autoFocus={autoFocus} />
<Text {...props} field="position" label={<Trans>Position</Trans>} />
<Text {...props} field="phone" label={<Trans>Phone</Trans>} wide />
<Link {...props} />
<Description {...props} />
</>
);
case "profiles":
return (
<>
<Text {...props} field="network" label={<Trans>Network</Trans>} autoFocus={autoFocus} />
<Text {...props} field="username" label={<Trans>Username</Trans>} />
<Link {...props} />
<MoreOptions>
<IconAndColor {...props} />
</MoreOptions>
</>
);
case "summary":
return <Description {...props} field="content" label={t`Text`} />;
case "cover-letter":
return (
<>
<Description {...props} field="recipient" label={t`Recipient`} />
<Description {...props} field="content" label={t`Letter`} />
</>
);
}
}
@@ -0,0 +1,340 @@
import type { MessageDescriptor } from "@lingui/core";
import type { KeyboardEvent, ReactNode } from "react";
import type { PageSettings } from "./entries";
import type { Entry, WriteSection } from "./model";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { msg, t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useEffect, useMemo } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
import { useEditorStore } from "../store";
import { EntryFields } from "./entries";
import { useEntry, useEntryWriter } from "./fields";
import { createEntry, describeEntry, getEntries, getPrimaryField, isDraftEntry } from "./model";
import { useSectionTitle } from "./section-row";
const DRAFT_HINTS: Record<string, MessageDescriptor> = {
company: msg`Appears on the page once it has a company.`,
school: msg`Appears on the page once it has a school.`,
name: msg`Appears on the page once it has a name.`,
language: msg`Appears on the page once it has a language.`,
title: msg`Appears on the page once it has a title.`,
organization: msg`Appears on the page once it has an organization.`,
network: msg`Appears on the page once it has a network.`,
};
type EntryCardProps = {
section: WriteSection;
entryId: string;
index: number;
count: number;
page: PageSettings;
locked: boolean;
/** ⌥↑ / ⌥↓ on the title. */
onMove: (entryId: string, direction: "up" | "down") => void;
};
/**
* An entry in the outline. Collapsed: its title and "company · location · dates". Open (one at a time, the
* editor's selection): its fields, saved as you type. Deleting is immediate, with Undo in the toast.
*/
export function EntryCard({ section, entryId, index, count, page, locked, onMove }: EntryCardProps) {
const { i18n } = useLingui();
const entry = useEntry(section.id, entryId);
const write = useEntryWriter(section.id, entryId);
const open = useEditorStore((state) => state.selection?.kind === "item" && state.selection.itemId === entryId);
const autoFocus = useEditorStore((state) => state.focusEntryId === entryId);
const select = useEditorStore((state) => state.select);
const sortable = useSortable({ id: entryId, disabled: locked });
const isPhone = useBreakpoint() === "mobile";
// The first field took focus as it mounted; later openings shouldn't steal focus again.
useEffect(() => {
if (autoFocus) useEditorStore.getState().setFocusEntry(null);
}, [autoFocus]);
if (!entry) return null;
const { title, meta } = describeEntry(section.type, entry);
const draft = isDraftEntry(section.type, entry);
const primaryField = getPrimaryField(section.type);
const hint = draft && primaryField ? DRAFT_HINTS[primaryField] : undefined;
const toggle = () => select(open ? null : { kind: "item", sectionId: section.id, itemId: entryId });
const fields = (
<>
{hint && <p className="col-span-full text-ink-3 text-xs">{i18n._(hint)}</p>}
<EntryFields type={section.type} entry={entry} write={write} page={page} autoFocus={autoFocus} />
</>
);
const onTitleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
event.preventDefault();
onMove(entryId, event.key === "ArrowUp" ? "up" : "down");
};
return (
<div
ref={sortable.setNodeRef}
id={atsFindingItemElementId(entryId)}
data-entry-id={entryId}
style={{ transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition }}
className={cn(
"group/entry relative scroll-mt-[60px] rounded-[10px] border border-line bg-surface transition-[border-color,box-shadow] duration-quick",
open && "border-accent shadow-e1",
sortable.isDragging && "z-10 opacity-40",
)}
>
<div className="flex items-center gap-0.5 pe-1">
{!locked && (
<button
type="button"
aria-label={t`Reorder ${title || t`Untitled`}`}
className="-ms-px flex h-10 w-5 shrink-0 cursor-grab items-center justify-center text-ink-3 opacity-0 transition-opacity duration-quick focus-visible:opacity-100 active:cursor-grabbing group-hover/entry:opacity-100"
{...sortable.attributes}
{...sortable.listeners}
>
<Icon name="drag_indicator" size={16} />
</button>
)}
<button
type="button"
aria-expanded={open}
aria-label={t`${title || t`Untitled`}, entry ${index + 1} of ${count}`}
onClick={toggle}
onKeyDown={onTitleKeyDown}
className={cn(
"flex min-w-0 flex-1 flex-col items-start gap-0.5 rounded-[9px] py-2.5 pe-2 text-start",
locked ? "ps-3" : "ps-0.5",
)}
>
<span className="flex max-w-full items-center gap-2">
<span className={cn("truncate font-semibold text-[13px] leading-[18px]", !title && "text-ink-3")}>
{title || <Trans>Untitled</Trans>}
</span>
{draft && (
<Badge variant="neutral" className="shrink-0">
<Trans>Draft · not printed</Trans>
</Badge>
)}
{entry.hidden && (
<Badge variant="outline" className="shrink-0">
<Trans>Hidden</Trans>
</Badge>
)}
</span>
{meta && <span className="max-w-full truncate text-ink-3 text-xs leading-4">{meta}</span>}
</button>
{open && !locked && (
<IconButton
icon="delete"
label={t`Delete entry`}
size="icon-sm"
className="text-ink-2 hover:bg-danger-soft hover:text-danger-text"
onClick={() => deleteEntry(section, entryId)}
/>
)}
{!locked && <EntryMenu section={section} entry={entry} />}
</div>
{open && !isPhone && (
<fieldset
disabled={locked}
className="m-0 grid min-w-0 grid-cols-2 gap-x-3 gap-y-2.5 border-0 border-line border-t px-3 pt-3 pb-3.5"
>
{fields}
</fieldset>
)}
{open && isPhone && (
<PhoneEntryScreen
section={section}
title={title}
locked={locked}
onDelete={() => deleteEntry(section, entryId)}
onBack={() => select(null)}
>
{fields}
</PhoneEntryScreen>
)}
</div>
);
}
type PhoneEntryScreenProps = {
section: WriteSection;
title: string;
locked: boolean;
onBack: () => void;
onDelete: () => void;
children: ReactNode;
};
/** Phones: an open entry pushes in full screen, with a back label naming its section and 44px fields. */
function PhoneEntryScreen({ section, title, locked, onBack, onDelete, children }: PhoneEntryScreenProps) {
const sectionTitle = useSectionTitle(section);
return (
<div
role="dialog"
aria-label={title || t`Untitled`}
className="fixed inset-0 z-40 flex flex-col bg-surface pb-[env(safe-area-inset-bottom)]"
>
<div className="flex h-14 shrink-0 items-center gap-1 border-line border-b px-1.5">
<button
type="button"
onClick={onBack}
className="flex h-11 items-center gap-0.5 rounded-lg px-2 font-medium text-[15px] text-accent-text"
>
<Icon name="chevron_left" size={24} />
{sectionTitle}
</button>
<span className="min-w-0 flex-1" />
{!locked && (
<IconButton icon="delete" label={t`Delete entry`} className="text-danger-text" onClick={onDelete} />
)}
</div>
<fieldset
disabled={locked}
className="m-0 grid min-w-0 flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-3 overflow-y-auto border-0 p-4 [&_input]:h-11"
>
{children}
</fieldset>
</div>
);
}
function deleteEntry(section: WriteSection, entryId: string) {
const { updateResumeData, undo } = useResumeStore.getState();
updateResumeData(
(draft) => {
const entries = getEntries(draft, section);
const index = entries.findIndex((entry) => entry.id === entryId);
if (index !== -1) entries.splice(index, 1);
},
{ newStep: true },
);
useEditorStore.getState().select(null);
toast.add({ description: t`Entry deleted`, actionProps: { children: t`Undo`, onClick: undo } });
}
type EntryMenuProps = { section: WriteSection; entry: Entry };
/** Hide from page, Duplicate, Move to… and Delete. */
function EntryMenu({ section, entry }: EntryMenuProps) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
const customSectionId = section.kind === "custom" ? section.id : undefined;
const moveTargets = useMemo(
() => getCompatibleMoveTargets(resume.data, section.type, customSectionId),
[resume.data, section.type, customSectionId],
);
const sourceTitle = useMemo(
() => getSourceSectionTitle(resume.data, section.type, customSectionId),
[resume.data, section.type, customSectionId],
);
const move = (target: Parameters<typeof moveItem>[1]["target"]) =>
updateResumeData((draft) => moveItem(draft, { itemId: entry.id, type: section.type, customSectionId, target }), {
newStep: true,
});
const duplicate = () => {
const copy = { ...structuredClone(entry), id: createEntry(section.type).id } as Entry;
updateResumeData(
(draft) => {
const entries = getEntries(draft, section);
entries.splice(entries.findIndex((item) => item.id === entry.id) + 1, 0, copy);
},
{ newStep: true },
);
useEditorStore.getState().select({ kind: "item", sectionId: section.id, itemId: copy.id });
};
const toggleHidden = () =>
updateResumeData(
(draft) => {
const target = getEntries(draft, section).find((item) => item.id === entry.id);
if (target) target.hidden = !target.hidden;
},
{ newStep: true },
);
return (
<DropdownMenu>
<DropdownMenuTrigger
render={<IconButton icon="more_horiz" label={t`Entry options`} size="icon-sm" className="text-ink-2" />}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={toggleHidden}>
<Icon name={entry.hidden ? "visibility" : "visibility_off"} />
{entry.hidden ? <Trans>Show on page</Trans> : <Trans>Hide from page</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={duplicate}>
<Icon name="content_copy" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Icon name="arrow_forward" />
<Trans>Move to…</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
{moveTargets.map(({ pageIndex, sections }) => (
<DropdownMenuSub key={pageIndex}>
<DropdownMenuSubTrigger>
<Icon name="description" />
<Trans>Page {pageIndex + 1}</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{sections.map(({ sectionId, sectionTitle }) => (
<DropdownMenuItem key={sectionId} onClick={() => move({ type: "section", sectionId })}>
{sectionTitle}
</DropdownMenuItem>
))}
{sections.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={() => move({ type: "new-section", title: sourceTitle, pageIndex })}>
<Icon name="add" />
<Trans>New section</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => move({ type: "new-page", title: sourceTitle })}>
<Icon name="note_add" />
<Trans>New page</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => deleteEntry(section, entry.id)}>
<Icon name="delete" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,168 @@
import type { Website } from "@reactive-resume/schema/resume/data";
import type { ComponentProps, ReactNode } from "react";
import type { Entry } from "./model";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useCallback, useState } from "react";
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { cn } from "@reactive-resume/utils/style";
import { URLInput } from "@/components/input/url-input";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { findEntry } from "./model";
/** One entry of a section, re-rendering only when that entry changes; null once it's deleted. */
export const useEntry = (sectionId: string, entryId: string) =>
useCurrentBuilderResumeSelector((resume) => findEntry(resume.data, sectionId, entryId) ?? null);
export type EntryWriter = (key: string, mutate: (entry: Record<string, unknown>) => void) => void;
/**
* Writes one field of an entry. The key names the field, so typing in one field folds into one undo step
* while a different field starts a new one.
*/
export function useEntryWriter(sectionId: string, entryId: string): EntryWriter {
const updateResumeData = useUpdateResumeData();
return useCallback(
(key, mutate) => {
updateResumeData(
(draft) => {
const entry = findEntry(draft, sectionId, entryId);
if (entry) mutate(entry as Entry & Record<string, unknown>);
},
{ coalesceKey: `${entryId}:${key}` },
);
},
[updateResumeData, sectionId, entryId],
);
}
type Validator = (value: string) => string | undefined;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** Says how to fix an email address rather than only that it's wrong. */
export const validateEmail: Validator = (value) => {
const email = value.trim();
if (!email || EMAIL.test(email)) return undefined;
const [name, domain] = email.split("@");
if (!name || domain === undefined) return t`Add an @ and the domain, like name@example.com`;
if (!domain) return t`Add the domain after the @, like ${name}@example.com`;
return t`Add the domain ending, like ${domain}.com`;
};
/** Accepts web addresses with or without https://, as the page links them either way. */
const validateUrl: Validator = (value) => {
const url = value.trim();
if (!url) return undefined;
try {
const parsed = new URL(/^[a-z]+:\/\//i.test(url) ? url : `https://${url}`);
if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname.includes(".")) throw new Error();
return undefined;
} catch {
return t`Enter a web address, like example.com`;
}
};
type TextFieldProps = Omit<ComponentProps<typeof Input>, "value" | "onChange"> & {
label: ReactNode;
value: string;
onChange: (value: string) => void;
/** Checked after the field is first left, then as you type. */
validate?: Validator;
hint?: ReactNode;
wide?: boolean;
};
/** A labelled text field. Errors appear after the first blur and say how to fix them. */
export function TextField({
label,
value,
onChange,
validate,
hint,
wide,
className,
onBlur,
...props
}: TextFieldProps) {
const [touched, setTouched] = useState(false);
const error = touched ? validate?.(value) : undefined;
return (
<FormItem hasError={Boolean(error)} className={cn(wide && "col-span-full")}>
<FormLabel>{label}</FormLabel>
<FormControl
render={
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={(event) => {
setTouched(true);
onBlur?.(event);
}}
className={className}
{...props}
/>
}
/>
<FormMessage errors={error ? [error] : undefined} />
{hint && !error && <FormDescription>{hint}</FormDescription>}
</FormItem>
);
}
type WebsiteFieldProps = {
label?: ReactNode;
value: Website & { inlineLink?: boolean };
onChange: (value: Website & { inlineLink?: boolean }) => void;
/** Entries can print the link on their title instead of on its own line. */
allowInlineLink?: boolean;
};
export function WebsiteField({ label, value, onChange, allowInlineLink = true }: WebsiteFieldProps) {
const [touched, setTouched] = useState(false);
const error = touched ? validateUrl(value.url) : undefined;
const inline = value.inlineLink ?? false;
return (
<div className="col-span-full grid gap-2">
<FormItem hasError={Boolean(error)}>
<FormLabel>{label ?? <Trans>Link</Trans>}</FormLabel>
<FormControl
render={
<URLInput value={value} onChange={onChange} hideLabelButton={inline} onBlur={() => setTouched(true)} />
}
/>
<FormMessage errors={error ? [error] : undefined} />
</FormItem>
{allowInlineLink && (
// biome-ignore lint/a11y/noLabelWithoutControl: Base UI's Checkbox is the control; wrapping it in a label is its documented pattern.
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm">
<Checkbox checked={inline} onCheckedChange={(checked) => onChange({ ...value, inlineLink: checked })} />
<Trans>Show link in title</Trans>
</label>
)}
</div>
);
}
type MoreOptionsProps = { children: ReactNode };
/** Secondary fields (level, icon, colour) stay out of the way until asked for. */
export function MoreOptions({ children }: MoreOptionsProps) {
return (
<details className="group/more col-span-full">
<summary className="flex w-fit cursor-pointer list-none items-center gap-1 rounded-sm font-medium text-[13px] text-ink-2 hover:text-ink [&::-webkit-details-marker]:hidden">
<span aria-hidden="true" className="transition-transform duration-quick group-open/more:rotate-90">
›
</span>
<Trans>More options</Trans>
</summary>
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2.5">{children}</div>
</details>
);
}
@@ -0,0 +1,164 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it } from "vitest";
import { produce } from "immer";
import { customSectionItemDefinitionByType } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import {
addBuiltinSection,
addCustomSection,
CUSTOM_SECTION_TYPES,
countEntriesToCheck,
createEntry,
describeEntry,
getOutlineRows,
isDraftEntry,
isSectionInUse,
moveSection,
resolveSection,
} from "./model";
const resume = (edit?: (data: ResumeData) => void) => {
const data = structuredClone(defaultResumeData);
data.metadata.layout.pages = [
{ fullWidth: false, main: ["summary", "experience", "education"], sidebar: ["skills"] },
];
edit?.(data);
return data;
};
const withEntries = (data: ResumeData, ...types: ("experience" | "education" | "skills")[]) => {
for (const type of types) {
const entry = createEntry(type) as { name?: string; company?: string; school?: string };
if (type === "experience") entry.company = "Lumen";
if (type === "education") entry.school = "UdK";
if (type === "skills") entry.name = "Figma";
(data.sections[type].items as unknown[]).push(entry);
}
};
describe("createEntry", () => {
it.each(CUSTOM_SECTION_TYPES)("makes a %s entry the schema accepts", (type) => {
const entry = createEntry(type);
expect(customSectionItemDefinitionByType[type].schema.safeParse(entry).success).toBe(true);
});
});
describe("drafts", () => {
it("treats an entry without its primary field as a draft", () => {
expect(isDraftEntry("experience", createEntry("experience"))).toBe(true);
expect(isDraftEntry("experience", { ...createEntry("experience"), company: "Lumen" } as never)).toBe(false);
expect(isDraftEntry("summary", createEntry("summary"))).toBe(false);
});
});
describe("describeEntry", () => {
it("titles experience by position, then company · location · dates", () => {
const entry = {
...createEntry("experience"),
position: "Designer",
company: "Lumen",
location: "Berlin",
period: "Mar 2022 – Present",
};
expect(describeEntry("experience", entry as never)).toEqual({
title: "Designer",
meta: "Lumen · Berlin · Mar 2022 – Present",
});
});
});
describe("outline", () => {
it("lists sections in use in print order: main, then sidebar", () => {
const data = resume((draft) => withEntries(draft, "skills", "education"));
expect(getOutlineRows(data)).toEqual([
{ id: "education", page: 0, column: "main" },
{ id: "skills", page: 0, column: "sidebar" },
]);
});
it("keeps just-added and custom sections, and lists unplaced sections last", () => {
const data = resume((draft) => {
withEntries(draft, "experience");
draft.sections.projects.items = [createEntry("projects") as never];
draft.customSections = [{ ...structuredClone(draft.sections.skills), id: "custom-1", type: "skills" } as never];
});
expect(getOutlineRows(data, new Set(["summary"])).map((row) => row.id)).toEqual([
"summary",
"experience",
"projects",
"custom-1",
]);
expect(isSectionInUse(data, "summary")).toBe(false);
});
it("moves a section before a row when moving up and after it when moving down", () => {
const data = resume((draft) => withEntries(draft, "experience", "education", "skills"));
const up = produce(data, (draft) =>
moveSection(draft, "education", { id: "experience", page: 0, column: "main" }, "up"),
);
expect(up.metadata.layout.pages[0]?.main).toEqual(["summary", "education", "experience"]);
const down = produce(data, (draft) =>
moveSection(draft, "experience", { id: "education", page: 0, column: "main" }, "down"),
);
expect(down.metadata.layout.pages[0]?.main).toEqual(["summary", "education", "experience"]);
});
it("changes column when a section moves across the sidebar divider", () => {
const data = resume((draft) => withEntries(draft, "experience", "skills"));
const moved = produce(data, (draft) =>
moveSection(draft, "skills", { id: "experience", page: 0, column: "main" }, "up"),
);
expect(moved.metadata.layout.pages[0]).toMatchObject({
main: ["summary", "skills", "experience", "education"],
sidebar: [],
});
});
});
describe("add section", () => {
it("shows and places a built-in section with one draft entry", () => {
const data = resume((draft) => {
draft.sections.projects.hidden = true;
});
let entryId: string | null = null;
const next = produce(data, (draft) => {
entryId = addBuiltinSection(draft, "projects");
});
expect(next.sections.projects.hidden).toBe(false);
expect(next.metadata.layout.pages[0]?.main).toContain("projects");
expect(next.sections.projects.items).toHaveLength(1);
expect(next.sections.projects.items[0]?.id).toBe(entryId);
expect(isDraftEntry("projects", next.sections.projects.items[0] as never)).toBe(true);
});
it("creates a custom section of a type, named and placed, with one draft entry", () => {
let ids = { sectionId: "", entryId: "" };
const next = produce(resume(), (draft) => {
ids = addCustomSection(draft, "awards", "Honours");
});
expect(resolveSection(next, ids.sectionId)).toEqual({ id: ids.sectionId, kind: "custom", type: "awards" });
expect(next.customSections[0]).toMatchObject({ title: "Honours", items: [{ id: ids.entryId }] });
expect(next.metadata.layout.pages[0]?.main.at(-1)).toBe(ids.sectionId);
});
});
describe("countEntriesToCheck", () => {
it("counts entries whose dates, or whose roles' dates, still carry text to review", () => {
const raw = { start: null, end: null, present: false, raw: "Summer 2016" };
const exact = { start: "2016", end: null, present: false };
const entries = [
{ ...createEntry("experience"), dates: raw },
{
...createEntry("experience"),
dates: exact,
roles: [{ id: "r", position: "", period: "", description: "", dates: raw }],
},
{ ...createEntry("experience"), dates: exact },
];
expect(countEntriesToCheck(entries as never)).toBe(2);
});
});
@@ -0,0 +1,334 @@
import type {
CustomSection,
CustomSectionItem,
CustomSectionType,
ResumeData,
SectionType,
} from "@reactive-resume/schema/resume/data";
import type { WritableDraft } from "immer";
import { EMPTY_RESUME_DATES } from "@reactive-resume/schema/resume/dates";
import { generateId, stripHtml } from "@reactive-resume/utils/string";
type Data = ResumeData | WritableDraft<ResumeData>;
/** One entry of any section. Each section type narrows it in its own field set. */
export type Entry = CustomSectionItem;
/** A section of the outline: the summary, a built-in section (keyed by type) or a custom section (by id). */
export type WriteSection = { id: string; kind: "summary" | "builtin" | "custom"; type: CustomSectionType };
export const BUILTIN_SECTION_TYPES = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
] as const satisfies readonly SectionType[];
export const CUSTOM_SECTION_TYPES = [
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
"profiles",
"summary",
"cover-letter",
] as const satisfies readonly CustomSectionType[];
const isBuiltinSectionType = (id: string): id is SectionType =>
(BUILTIN_SECTION_TYPES as readonly string[]).includes(id);
export const getSectionKind = (id: string): WriteSection["kind"] =>
id === "summary" ? "summary" : isBuiltinSectionType(id) ? "builtin" : "custom";
/** The type of a section id: its own key for the summary and built-ins, the stored type for custom ones. */
export function getSectionType(data: Data, id: string): CustomSectionType | undefined {
if (id === "summary") return "summary";
if (isBuiltinSectionType(id)) return id;
return data.customSections.find((section) => section.id === id)?.type;
}
export function resolveSection(data: Data, id: string): WriteSection | null {
const type = getSectionType(data, id);
return type ? { id, kind: getSectionKind(id), type } : null;
}
/** The entries of a section, from saved data or inside an immer draft. The summary has none. */
export function getEntries(data: Data, section: WriteSection): Entry[] {
if (section.kind === "builtin") return data.sections[section.id as SectionType].items as Entry[];
if (section.kind === "custom")
return (data.customSections.find((custom) => custom.id === section.id)?.items ?? []) as Entry[];
return [];
}
type SectionObject = ResumeData["summary"] | ResumeData["sections"][SectionType] | CustomSection;
/** The section's own settings (title, icon, columns, hidden…), which every kind of section shares. */
export function getSectionObject(data: Data, section: WriteSection): SectionObject | undefined {
if (section.kind === "summary") return data.summary;
if (section.kind === "builtin") return data.sections[section.id as SectionType];
return data.customSections.find((custom) => custom.id === section.id);
}
export function findEntry(data: Data, sectionId: string, entryId: string): Entry | undefined {
const section = resolveSection(data, sectionId);
return section ? getEntries(data, section).find((entry) => entry.id === entryId) : undefined;
}
/** The field an entry needs before it prints, matching the PDF renderer's filter. Others always print. */
const PRIMARY_FIELD: Partial<Record<CustomSectionType, string>> = {
profiles: "network",
experience: "company",
education: "school",
projects: "name",
skills: "name",
languages: "language",
interests: "name",
awards: "title",
certifications: "title",
publications: "title",
volunteer: "organization",
references: "name",
};
export const getPrimaryField = (type: CustomSectionType) => PRIMARY_FIELD[type];
/** An entry without its primary field is a draft: it's kept, but not printed. */
export function isDraftEntry(type: CustomSectionType, entry: Entry): boolean {
const field = PRIMARY_FIELD[type];
if (!field) return false;
const value = (entry as Record<string, unknown>)[field];
return typeof value !== "string" || !value.trim();
}
const website = () => ({ url: "", label: "", inlineLink: false });
const dates = () => ({ ...EMPTY_RESUME_DATES });
/** A new, empty entry of a type: the starting point for "+ Add" and for Add section's first draft. */
export function createEntry(type: CustomSectionType): Entry {
const base = { id: generateId(), hidden: false };
const fields: Record<CustomSectionType, Record<string, unknown>> = {
profiles: { icon: "", iconColor: "", network: "", username: "", website: website() },
experience: {
company: "",
position: "",
location: "",
period: "",
dates: dates(),
website: website(),
description: "",
roles: [],
},
education: {
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
dates: dates(),
website: website(),
description: "",
},
projects: { name: "", period: "", dates: dates(), website: website(), description: "" },
skills: { icon: "", iconColor: "", name: "", proficiency: "", level: 0, keywords: [] },
languages: { language: "", fluency: "", level: 0 },
interests: { icon: "", iconColor: "", name: "", keywords: [] },
awards: { title: "", awarder: "", date: "", dates: dates(), website: website(), description: "" },
certifications: { title: "", issuer: "", date: "", dates: dates(), website: website(), description: "" },
publications: { title: "", publisher: "", date: "", dates: dates(), website: website(), description: "" },
volunteer: { organization: "", location: "", period: "", dates: dates(), website: website(), description: "" },
references: { name: "", position: "", website: website(), phone: "", description: "" },
summary: { content: "" },
"cover-letter": { recipient: "", content: "" },
};
return { ...base, ...fields[type] } as Entry;
}
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const join = (...parts: unknown[]) => parts.map(text).filter(Boolean).join(" · ");
/**
* How an entry reads in the outline: a title and a line of details ("company · location · dates"). Dates come
* from the legacy text, which is kept formatted from the structured dates.
*/
export function describeEntry(type: CustomSectionType, entry: Entry): { title: string; meta: string } {
const e = entry as Record<string, unknown> & { website?: { label?: string }; keywords?: string[] };
const keywords = (e.keywords ?? []).join(", ");
switch (type) {
case "experience": {
const position = text(e.position);
return { title: position || text(e.company), meta: join(position ? e.company : "", e.location, e.period) };
}
case "education":
return {
title: text(e.school),
meta: join([text(e.degree), text(e.area)].filter(Boolean).join(", "), e.location, e.period),
};
case "projects":
return { title: text(e.name), meta: join(e.period, e.website?.label) };
case "skills":
return { title: text(e.name), meta: join(e.proficiency, keywords) };
case "languages":
return { title: text(e.language), meta: join(e.fluency) };
case "interests":
return { title: text(e.name), meta: keywords };
case "awards":
return { title: text(e.title), meta: join(e.awarder, e.date) };
case "certifications":
return { title: text(e.title), meta: join(e.issuer, e.date) };
case "publications":
return { title: text(e.title), meta: join(e.publisher, e.date) };
case "volunteer":
return { title: text(e.organization), meta: join(e.location, e.period) };
case "references":
return { title: text(e.name), meta: join(e.position) };
case "profiles":
return { title: text(e.network), meta: join(e.username) };
case "summary":
case "cover-letter": {
const body = stripHtml(String(e.content ?? "")).trim();
return { title: body.length > 80 ? `${body.slice(0, 80)}…` : body, meta: "" };
}
}
}
/** Entries (and roles) whose dates came from text that couldn't be read exactly and still ask for a look. */
export function countEntriesToCheck(entries: readonly Entry[]): number {
let count = 0;
type WithDates = { dates?: { raw?: string }; roles?: readonly { dates?: { raw?: string } }[] };
for (const entry of entries as readonly WithDates[]) {
if (entry.dates?.raw !== undefined || entry.roles?.some((role) => role.dates?.raw !== undefined)) count += 1;
}
return count;
}
/** Sections that have something in them. The rest wait in Add section. Custom sections always count. */
export function isSectionInUse(data: Data, id: string, recentlyAdded: ReadonlySet<string> = new Set()): boolean {
if (recentlyAdded.has(id)) return true;
const section = resolveSection(data, id);
if (!section) return false;
if (section.kind === "summary") return Boolean(stripHtml(data.summary.content).trim());
if (section.kind === "custom") return true;
return getEntries(data, section).length > 0;
}
type LayoutColumn = "main" | "sidebar";
/** A row of the outline, in print order, with where it sits in the layout. */
export type OutlineRow = { id: string; page: number; column: LayoutColumn };
/**
* The outline lists the sections in use in print order: page by page, the main column and then the sidebar.
* Sections in use that no page places (rare) are listed last, as the last page's main column.
*/
export function getOutlineRows(data: Data, recentlyAdded?: ReadonlySet<string>): OutlineRow[] {
const rows: OutlineRow[] = [];
const placed = new Set<string>();
data.metadata.layout.pages.forEach((page, pageIndex) => {
for (const column of ["main", "sidebar"] as const) {
for (const id of page[column]) {
placed.add(id);
if (isSectionInUse(data, id, recentlyAdded)) rows.push({ id, page: pageIndex, column });
}
}
});
const lastPage = Math.max(0, data.metadata.layout.pages.length - 1);
const candidates = ["summary", ...BUILTIN_SECTION_TYPES, ...data.customSections.map((section) => section.id)];
for (const id of candidates) {
if (!placed.has(id) && isSectionInUse(data, id, recentlyAdded)) rows.push({ id, page: lastPage, column: "main" });
}
return rows;
}
function removeFromLayout(draft: WritableDraft<ResumeData>, id: string) {
for (const page of draft.metadata.layout.pages) {
page.main = page.main.filter((section) => section !== id);
page.sidebar = page.sidebar.filter((section) => section !== id);
}
}
/**
* Moves a section to where another row sits: before it when moving up, after it when moving down. Rows in a
* different page or column take the section with them, so dragging across the sidebar divider changes columns.
*/
export function moveSection(
draft: WritableDraft<ResumeData>,
id: string,
target: OutlineRow,
direction: "up" | "down",
) {
if (id === target.id) return;
removeFromLayout(draft, id);
const page = draft.metadata.layout.pages[target.page];
if (!page) return;
const column = page[target.column];
const index = column.indexOf(target.id);
const insertAt = index === -1 ? column.length : direction === "down" ? index + 1 : index;
column.splice(insertAt, 0, id);
}
/** Puts a section on the last page's main column unless a page already places it. */
function ensurePlaced(draft: WritableDraft<ResumeData>, id: string) {
const pages = draft.metadata.layout.pages;
if (pages.some((page) => page.main.includes(id) || page.sidebar.includes(id))) return;
if (pages.length === 0) pages.push({ fullWidth: false, main: [], sidebar: [] });
pages[pages.length - 1]?.main.push(id);
}
/**
* Add section: shows the section, places it if needed and gives it one draft entry to fill in. Returns the
* new entry's id (none for the summary, which is one rich text).
*/
export function addBuiltinSection(draft: WritableDraft<ResumeData>, type: "summary" | SectionType): string | null {
ensurePlaced(draft, type);
if (type === "summary") {
draft.summary.hidden = false;
return null;
}
const section = draft.sections[type];
section.hidden = false;
const entry = createEntry(type);
(section.items as Entry[]).push(entry);
return entry.id;
}
/** Add section → Custom section: a new section of a type, named after it, with one draft entry. */
export function addCustomSection(draft: WritableDraft<ResumeData>, type: CustomSectionType, title: string) {
const entry = createEntry(type);
const section = {
id: generateId(),
title,
type,
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [entry],
} as CustomSection;
draft.customSections.push(section);
ensurePlaced(draft, section.id);
return { sectionId: section.id, entryId: entry.id };
}
@@ -0,0 +1,216 @@
import type { DragEndEvent } from "@dnd-kit/core";
import type { MessageDescriptor } from "@lingui/core";
import type { CustomSectionType } from "@reactive-resume/schema/resume/data";
import type { PageSettings } from "./entries";
import type { OutlineRow, WriteSection } from "./model";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { Fragment, useMemo } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useEditorStore } from "../store";
import { addEntryTo } from "./actions";
import { EntryCard } from "./entry-card";
import { getEntries, getOutlineRows, getSectionKind, getSectionType, moveSection } from "./model";
import { SectionRow } from "./section-row";
import { SummaryEditor } from "./summary-editor";
const ADD_LABELS: Record<CustomSectionType, MessageDescriptor> = {
summary: msg`Add text`,
experience: msg`Add experience`,
education: msg`Add education`,
projects: msg`Add project`,
skills: msg`Add skill`,
languages: msg`Add language`,
interests: msg`Add interest`,
awards: msg`Add award`,
certifications: msg`Add certification`,
publications: msg`Add publication`,
volunteer: msg`Add volunteering`,
references: msg`Add reference`,
profiles: msg`Add profile`,
"cover-letter": msg`Add cover letter`,
};
const rowKey = (row: OutlineRow) => `${row.id}\u0000${row.page}\u0000${row.column}`;
function parseRows(key: string): OutlineRow[] {
if (!key) return [];
return key.split("\u0001").map((part) => {
const [id = "", page = "0", column = "main"] = part.split("\u0000");
return { id, page: Number(page), column: column as OutlineRow["column"] };
});
}
function useSortSensors() {
return useSensors(
// A small distance keeps clicks on the handle from starting a drag.
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
}
type OutlineProps = { locked: boolean; page: PageSettings };
/**
* The outline: sections in use, in print order. Dragging a row (or ⌥↑ / ⌥↓ on its title) reorders the
* layout; a divider marks each new page and, on two-column pages, where the sidebar starts.
*/
export function Outline({ locked, page }: OutlineProps) {
const added = useEditorStore((state) => state.addedSections);
const key = useCurrentBuilderResumeSelector((resume) =>
getOutlineRows(resume.data, new Set(added)).map(rowKey).join("\u0001"),
);
const rows = useMemo(() => parseRows(key), [key]);
const multiPage = rows.some((row) => row.page > 0);
const updateResumeData = useUpdateResumeData();
const sensors = useSortSensors();
const move = (id: string, target: OutlineRow | undefined, direction: "up" | "down") => {
if (!target) return;
updateResumeData((draft) => moveSection(draft, id, target, direction), { newStep: true });
};
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const from = rows.findIndex((row) => row.id === active.id);
const to = rows.findIndex((row) => row.id === over.id);
move(String(active.id), rows[to], from < to ? "down" : "up");
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={rows.map((row) => row.id)} strategy={verticalListSortingStrategy}>
<div className="grid gap-0.5">
{rows.map((row, index) => {
const previous = rows[index - 1];
const newPage = multiPage && row.page !== previous?.page;
const sidebarStarts = row.column === "sidebar" && (previous?.column !== "sidebar" || newPage);
return (
<Fragment key={row.id}>
{newPage && <Divider label={<Trans>Page {row.page + 1}</Trans>} />}
{sidebarStarts && <Divider label={<Trans>Sidebar</Trans>} subtle />}
<OutlineSection
sectionId={row.id}
locked={locked}
page={page}
onMove={(direction) => move(row.id, rows[direction === "up" ? index - 1 : index + 1], direction)}
/>
</Fragment>
);
})}
</div>
</SortableContext>
</DndContext>
);
}
function Divider({ label, subtle = false }: { label: React.ReactNode; subtle?: boolean }) {
return (
<div className="flex items-center gap-2 px-1 pt-2 pb-1">
<span
className={
subtle ? "text-[11px] text-ink-3" : "font-semibold text-[11px] text-ink-2 uppercase tracking-[0.08em]"
}
>
{label}
</span>
<span className="h-px flex-1 bg-line" />
</div>
);
}
type OutlineSectionProps = {
sectionId: string;
locked: boolean;
page: PageSettings;
onMove: (direction: "up" | "down") => void;
};
function OutlineSection({ sectionId, locked, page, onMove }: OutlineSectionProps) {
const { i18n } = useLingui();
// Select the type (a string) rather than a new section object, so the row renders only when it changes.
const type = useCurrentBuilderResumeSelector((resume) => getSectionType(resume.data, sectionId) ?? null);
const stableSection = useMemo<WriteSection | null>(
() => (type ? { id: sectionId, kind: getSectionKind(sectionId), type } : null),
[sectionId, type],
);
if (!stableSection) return null;
return (
<SectionRow section={stableSection} locked={locked} onMove={onMove}>
{stableSection.kind === "summary" ? (
<SummaryEditor locked={locked} />
) : (
<>
<EntryList section={stableSection} locked={locked} page={page} />
{!locked && (
<button
type="button"
onClick={() => addEntryTo(stableSection)}
className="flex h-9 items-center gap-1.5 rounded-lg px-2 text-accent-text text-sm hover:bg-hover"
>
<Icon name="add" size={18} />
{i18n._(ADD_LABELS[stableSection.type])}
</button>
)}
</>
)}
</SectionRow>
);
}
type EntryListProps = { section: WriteSection; locked: boolean; page: PageSettings };
/** Entries reorder like sections: drag the card's handle, or ⌥↑ / ⌥↓ on its title. */
function EntryList({ section, locked, page }: EntryListProps) {
const ids = useCurrentBuilderResumeSelector((resume) =>
getEntries(resume.data, section)
.map((entry) => entry.id)
.join("\u0000"),
);
const entryIds = useMemo(() => (ids ? ids.split("\u0000") : []), [ids]);
const updateResumeData = useUpdateResumeData();
const sensors = useSortSensors();
const move = (entryId: string, to: number) => {
if (to < 0 || to >= entryIds.length) return;
updateResumeData(
(draft) => {
const entries = getEntries(draft, section);
const from = entries.findIndex((entry) => entry.id === entryId);
const [entry] = entries.splice(from, 1);
if (entry) entries.splice(to, 0, entry);
},
{ newStep: true },
);
};
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
move(String(active.id), entryIds.indexOf(String(over.id)));
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={entryIds} strategy={verticalListSortingStrategy}>
{entryIds.map((entryId, index) => (
<EntryCard
key={entryId}
section={section}
entryId={entryId}
index={index}
count={entryIds.length}
page={page}
locked={locked}
onMove={(id, direction) => move(id, direction === "up" ? index - 1 : index + 1)}
/>
))}
</SortableContext>
</DndContext>
);
}
@@ -42,15 +42,6 @@ import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
export function PictureSectionBuilder() {
return (
<SectionBase type="picture">
<PictureSectionForm />
</SectionBase>
);
}
type PicturePreviewControlsProps = {
fileInputRef: React.RefObject<HTMLInputElement | null>;
@@ -502,7 +493,8 @@ type CropState = {
imageSrc: string;
};
function PictureSectionForm() {
/** Every photo option: upload with crop, address, show or hide, delete, fit, size, rotation, shape, border and shadow. */
export function PictureSettings() {
const fileInputRef = useRef<HTMLInputElement>(null);
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
@@ -0,0 +1,45 @@
import type { EditorSelection } from "../store";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { useEditorStore } from "../store";
/** Smooth scrolling unless the user asked for reduced motion. */
export const getScrollBehavior = (): ScrollBehavior =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
const SECTION_ANCHOR = "sidebar-";
const ITEM_ANCHOR = atsFindingItemElementId("");
/**
* The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing
* a field outlines its block on the page. The Basics card (and its photo) edit the header.
*/
export function selectionFromPanelElement(element: Element): EditorSelection | null {
const sectionId = element.closest(`[id^="${SECTION_ANCHOR}"]`)?.id.slice(SECTION_ANCHOR.length);
if (!sectionId) return null;
if (sectionId === "basics" || sectionId === "picture") return { kind: "header" };
const itemId = element.closest(`[id^="${ITEM_ANCHOR}"]`)?.id.slice(ITEM_ANCHOR.length);
return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId };
}
/**
* Page → panel: opens what was picked (the Basics card for the header; otherwise its section, with Basics
* collapsed) and scrolls it 60px from the top of the panel, instantly with reduced motion.
*/
export function revealSelectionInPanel(selection: EditorSelection) {
const editor = useEditorStore.getState();
editor.select(selection);
if (selection.kind === "header") editor.setBasicsOpen(true);
else {
editor.setBasicsOpen(false);
editor.setSectionOpen(selection.sectionId, true);
}
requestAnimationFrame(() => {
const section = document.getElementById(
selection.kind === "header" ? "sidebar-basics" : `sidebar-${selection.sectionId}`,
);
const entry = selection.kind === "item" ? document.getElementById(atsFindingItemElementId(selection.itemId)) : null;
(entry ?? section)?.scrollIntoView({ block: "start", behavior: getScrollBehavior() });
});
}
@@ -0,0 +1,190 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { Editor } from "@tiptap/react";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { hasUnsupportedTableMarkup, richInputExtensions } from "@/components/input/rich-input";
import { usePrompt } from "@/hooks/use-prompt";
type ToolbarAction = {
icon: IconName;
label: string;
isActive?: (editor: Editor) => boolean;
run: (editor: Editor) => void;
};
/**
* Bold, Italic, Link, then lists and Clear formatting. Formatting the toolbar doesn't offer (headings,
* colours, alignment…) still loads and prints; Clear formatting removes it.
*/
function useToolbarActions(): ToolbarAction[] {
const prompt = usePrompt();
return [
{
icon: "format_bold",
label: t`Bold`,
isActive: (editor) => editor.isActive("bold"),
run: (editor) => editor.chain().focus().toggleBold().run(),
},
{
icon: "format_italic",
label: t`Italic`,
isActive: (editor) => editor.isActive("italic"),
run: (editor) => editor.chain().focus().toggleItalic().run(),
},
{
icon: "link",
label: t`Link`,
isActive: (editor) => editor.isActive("link"),
run: async (editor) => {
const current = (editor.getAttributes("link").href as string | undefined) ?? "";
const href = await prompt(t`Link address`, {
defaultValue: current || "https://",
description: t`Leave it empty to remove the link.`,
});
if (href === null) return editor.commands.focus();
const chain = editor.chain().focus().extendMarkRange("link");
if (!href.trim() || href.trim() === "https://") chain.unsetLink().run();
else chain.setLink({ href: href.trim() }).run();
},
},
{
icon: "format_list_bulleted",
label: t`Bulleted list`,
isActive: (editor) => editor.isActive("bulletList"),
run: (editor) => editor.chain().focus().toggleBulletList().run(),
},
{
icon: "format_list_numbered",
label: t`Numbered list`,
isActive: (editor) => editor.isActive("orderedList"),
run: (editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
icon: "format_clear",
label: t`Clear formatting`,
run: (editor) => editor.chain().focus().clearNodes().unsetAllMarks().unsetTextAlign().run(),
},
];
}
type RichTextEditorProps = {
/** Accessible name of the text box. */
label: string;
value: string;
onChange: (html: string) => void;
/** Guidance under the text while editing, e.g. "2–3 sentences reads best". */
hint?: ReactNode;
disabled?: boolean;
className?: string;
};
/**
* Rich text for descriptions: the toolbar and footer show while the text has focus, the toolbar never
* takes focus from it, and Markdown shortcuts work ("- " starts a list, "**bold**").
*/
export function RichTextEditor({ label, value, onChange, hint, disabled = false, className }: RichTextEditorProps) {
const [focused, setFocused] = useState(false);
const actions = useToolbarActions();
const readOnlyTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]);
const editor = useEditor({
extensions: richInputExtensions,
content: value,
editable: !disabled && !readOnlyTable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": label,
"aria-multiline": "true",
role: "textbox",
spellcheck: "true",
class: cn(
"wysiwyg max-h-[360px] min-h-[88px] overflow-y-auto px-3 py-2 text-sm outline-none",
"[&_[data-resume-whitespace=preserve]]:whitespace-pre-wrap",
),
},
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
const state = useEditorState({
editor,
selector: ({ editor }) =>
editor
? { characters: editor.getText().length, active: actions.map((action) => action.isActive?.(editor) ?? false) }
: { characters: 0, active: [] as boolean[] },
});
// Undo, the page and the assistant change the text from outside; keep the editor in step.
useEffect(() => {
if (!editor || editor.getHTML() === value) return;
editor.commands.setContent(value, { emitUpdate: false });
}, [editor, value]);
useEffect(() => {
editor?.setEditable(!disabled && !readOnlyTable, false);
}, [editor, disabled, readOnlyTable]);
return (
<div
className={cn(
"rounded-lg border border-line-2 bg-raised transition-[border-color,box-shadow] duration-quick",
focused && "border-accent shadow-[0_0_0_3px_var(--accent-soft)]",
disabled && "bg-sunken",
className,
)}
>
{focused && !readOnlyTable && (
<div role="toolbar" aria-label={t`Formatting`} className="flex gap-0.5 border-line border-b px-1.5 py-1">
{actions.map((action, index) => (
<button
key={action.icon}
type="button"
aria-label={action.label}
aria-pressed={action.isActive ? (state?.active[index] ?? false) : undefined}
title={action.label}
// Keep the caret in the text while formatting.
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor && void action.run(editor)}
className={cn(
"flex size-8 items-center justify-center rounded-md text-ink-2 transition-colors duration-quick hover:bg-hover",
state?.active[index] && "bg-accent-soft text-accent-text",
index === 3 && "ms-1.5",
)}
>
<Icon name={action.icon} />
</button>
))}
</div>
)}
{readOnlyTable && (
<p role="status" className="border-line border-b px-3 py-2 text-ink-2 text-xs">
<Trans>
Original table formatting is preserved. This content is read-only because it cannot be edited safely.
</Trans>
</p>
)}
<EditorContent editor={editor} />
{focused && (
<div className="flex items-center justify-between gap-3 border-line border-t px-3 py-1.5 text-ink-3 text-xs">
<span>{hint ?? <Trans>Markdown shortcuts on</Trans>}</span>
<span className="font-mono">
<Plural value={state?.characters ?? 0} one="# character" other="# characters" />
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,385 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import type { KeyboardEvent, ReactNode } from "react";
import type { WriteSection } from "./model";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { plural, t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useState } from "react";
import { sortSectionItemsByPeriod } from "@reactive-resume/resume/section-sort";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentBuilderResumeSelector, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { usePrompt } from "@/hooks/use-prompt";
import { getSectionTitle } from "@/libs/resume/section";
import { useEditorStore } from "../store";
import { addEntryTo } from "./actions";
import { countEntriesToCheck, getEntries, getSectionObject } from "./model";
/** The printed title: the one the user gave, else the section type's name. */
export function useSectionTitle(section: WriteSection) {
const title = useCurrentBuilderResumeSelector((resume) => getSectionObject(resume.data, section)?.title ?? "");
return (
title.trim() || getSectionTitle(section.kind === "custom" ? section.type : (section.id as SectionType | "summary"))
);
}
type SectionRowProps = {
section: WriteSection;
locked: boolean;
/** ⌥↑ / ⌥↓ on the focused title. */
onMove: (direction: "up" | "down") => void;
children: ReactNode;
};
/**
* An outline row: drag handle, title (opens the section), entry count, the eye (hidden sections keep their
* content but aren't printed), the chevron and the ⋯ menu. Open, it shows the section's entries.
*/
export function SectionRow({ section, locked, onMove, children }: SectionRowProps) {
const title = useSectionTitle(section);
const hidden = useCurrentBuilderResumeSelector((resume) => getSectionObject(resume.data, section)?.hidden ?? false);
const count = useCurrentBuilderResumeSelector((resume) => getEntries(resume.data, section).length);
// Dates read from text that wasn't exact ask for a look (they print as typed until then).
const toCheck = useCurrentBuilderResumeSelector((resume) => countEntriesToCheck(getEntries(resume.data, section)));
const open = useEditorStore((state) => state.openSections.includes(section.id));
const setOpen = useEditorStore((state) => state.setSectionOpen);
const updateResumeData = useUpdateResumeData();
const sortable = useSortable({ id: section.id, disabled: locked });
const toggleHidden = () =>
updateResumeData(
(draft) => {
const target = getSectionObject(draft, section);
if (target) target.hidden = !target.hidden;
},
{ newStep: true },
);
const onTitleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
event.preventDefault();
onMove(event.key === "ArrowUp" ? "up" : "down");
};
return (
<div
ref={sortable.setNodeRef}
id={`sidebar-${section.id}`}
data-section-id={section.id}
style={{ transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition }}
className={cn("relative scroll-mt-[60px] rounded-lg", sortable.isDragging && "z-10 opacity-40")}
>
<div className="group/row flex h-11 items-center gap-0.5 rounded-lg pe-1 hover:bg-hover">
<button
type="button"
aria-label={t`Reorder ${title}`}
disabled={locked}
className="flex h-11 w-7 shrink-0 cursor-grab items-center justify-center text-ink-3 active:cursor-grabbing disabled:cursor-default"
{...sortable.attributes}
{...sortable.listeners}
>
<Icon name="drag_indicator" size={18} />
</button>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(section.id, !open)}
onKeyDown={onTitleKeyDown}
className={cn(
"min-w-0 flex-1 truncate rounded-md py-1 text-start font-medium text-sm",
hidden && "text-ink-3 line-through",
)}
>
{title}
</button>
{toCheck > 0 && (
<Badge variant="warn" className="shrink-0">
<Plural value={toCheck} one="# to check" other="# to check" />
</Badge>
)}
{section.kind !== "summary" && (
<span className="px-1 font-mono text-ink-3 text-xs">
<span aria-hidden="true">{count}</span>
<span className="sr-only">{plural(count, { one: "# entry", other: "# entries" })}</span>
</span>
)}
<IconButton
icon={hidden ? "visibility_off" : "visibility"}
label={hidden ? t`Show ${title} on the page` : t`Hide ${title} from the page`}
aria-pressed={hidden}
size="icon-sm"
disabled={locked}
className="text-ink-2"
onClick={toggleHidden}
/>
<IconButton
icon="expand_more"
label={open ? t`Close ${title}` : t`Open ${title}`}
size="icon-sm"
className={cn("text-ink-2 transition-transform duration-standard", open && "rotate-180")}
onClick={() => setOpen(section.id, !open)}
/>
{!locked && <SectionMenu section={section} title={title} onMove={onMove} />}
</div>
{open && <div className="grid gap-2 ps-[26px] pt-1 pb-3">{children}</div>}
</div>
);
}
type SectionMenuProps = { section: WriteSection; title: string; onMove: (direction: "up" | "down") => void };
/** Everything else a section can do: add, sort, heading, rename, icon, layout and reset or delete. */
function SectionMenu({ section, title, onMove }: SectionMenuProps) {
const prompt = usePrompt();
const updateResumeData = useUpdateResumeData();
const settings = useCurrentBuilderResumeSelector((resume) => getSectionObject(resume.data, section) ?? null);
const locale = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.locale);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
if (!settings) return null;
const edit = (mutate: (target: NonNullable<ReturnType<typeof getSectionObject>>) => void) =>
updateResumeData(
(draft) => {
const target = getSectionObject(draft, section);
if (target) mutate(target);
},
{ newStep: true },
);
const undoToast = (description: string) =>
toast.add({ description, actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() } });
const isSkills = section.type === "skills";
const canSort = section.type === "experience" || section.type === "education";
const columns =
isSkills && (settings as { layout?: string }).layout === "inline" ? "inline" : String(settings.columns);
const sortByDate = () => {
let unresolved: string[] = [];
updateResumeData(
(draft) => {
const entries = getEntries(draft, section) as never as {
id: string;
period: string;
company?: string;
school?: string;
}[];
const result = sortSectionItemsByPeriod(entries, locale);
unresolved = result.unresolvedIds.map((id) => {
const entry = entries.find((item) => item.id === id);
return (entry?.company || entry?.school || id).trim();
});
entries.splice(0, entries.length, ...result.items);
},
{ newStep: true },
);
if (unresolved.length > 0) {
toast.add({
type: "warning",
description: t`Could not sort these items; they stayed at the end: ${unresolved.join(", ")}.`,
});
}
};
const rename = async () => {
const next = await prompt(t`What do you want to rename this section to?`, {
description: t`Leave empty to reset the title to the original.`,
defaultValue: settings.title,
});
if (next === null || next === settings.title) return;
edit((target) => {
target.title = next;
});
};
const reset = () => {
edit((target) => {
if ("items" in target) target.items = [];
else target.content = "";
});
undoToast(t`${title} cleared`);
};
const remove = () => {
updateResumeData(
(draft) => {
draft.customSections = draft.customSections.filter((custom) => custom.id !== section.id);
for (const page of draft.metadata.layout.pages) {
page.main = page.main.filter((id) => id !== section.id);
page.sidebar = page.sidebar.filter((id) => id !== section.id);
}
},
{ newStep: true },
);
undoToast(t`${title} deleted`);
};
return (
<span className="relative">
<DropdownMenu>
<DropdownMenuTrigger
render={
<IconButton icon="more_horiz" label={t`Options for ${title}`} size="icon-sm" className="text-ink-2" />
}
/>
<DropdownMenuContent align="end" className="w-60">
{section.kind !== "summary" && (
<DropdownMenuItem onClick={() => addEntryTo(section)}>
<Icon name="add" />
<Trans>Add entry</Trans>
</DropdownMenuItem>
)}
{canSort && (
<DropdownMenuItem onClick={sortByDate}>
<Icon name="arrow_downward" />
<Trans>Sort by date</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onMove("up")}>
<Icon name="arrow_upward" />
<Trans>Move up</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onMove("down")}>
<Icon name="arrow_downward" />
<Trans>Move down</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={rename}>
<Icon name="edit" />
<Trans>Rename…</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIconPickerOpen(true)}>
<Icon name="bookmark" />
<Trans>Icon…</Trans>
</DropdownMenuItem>
<DropdownMenuCheckboxItem
checked={settings.showHeading !== false}
onCheckedChange={(checked) =>
edit((target) => {
target.showHeading = checked;
})
}
>
<Trans>Show heading</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Icon name="grid_view" />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={columns}
onValueChange={(value) =>
edit((target) => {
const skills = target as { layout?: string };
if (isSkills) skills.layout = value === "inline" ? "inline" : "default";
target.columns = value === "inline" ? 1 : Number(value);
})
}
>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={String(column)}>
<Plural value={column} one="# column" other="# columns" />
</DropdownMenuRadioItem>
))}
{isSkills && (
<DropdownMenuRadioItem value="inline">
<Trans>1 column, inline</Trans>
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{isSkills && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Icon name="format_list_bulleted" />
<Trans>Keyword layout</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={(settings as { keywordLayout?: string }).keywordLayout ?? "inline"}
onValueChange={(value) =>
edit((target) => {
(target as { keywordLayout?: string }).keywordLayout = value;
})
}
>
<DropdownMenuRadioItem value="inline">
<Trans>Inline</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">
<Trans>Bulleted list</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuCheckboxItem
checked={settings.keepTogether}
onCheckedChange={(checked) =>
edit((target) => {
target.keepTogether = checked;
})
}
>
<Trans>Keep on one page</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={settings.startOnNewPage}
onCheckedChange={(checked) =>
edit((target) => {
target.startOnNewPage = checked;
})
}
>
<Trans>Start on a new page</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={section.kind === "custom" ? remove : reset}>
<Icon name="delete" />
{section.kind === "custom" ? <Trans>Delete section</Trans> : <Trans>Clear section</Trans>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* The icon grid opens from the ⋯ button: an invisible trigger laid over it anchors the popover. */}
<IconPicker
value={settings.icon === "none" ? "" : settings.icon}
onChange={(icon) => {
edit((target) => {
target.icon = icon === "" ? "none" : icon;
});
setIconPickerOpen(false);
}}
popoverProps={{ open: iconPickerOpen, onOpenChange: setIconPickerOpen }}
aria-hidden
tabIndex={-1}
className="pointer-events-none absolute inset-0 size-full opacity-0"
/>
</span>
);
}
@@ -0,0 +1,27 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { RichTextEditor } from "./rich-text-editor";
/** The summary: one rich text, with the guidance the spec gives. Improve joins it in M10. */
export function SummaryEditor({ locked }: { locked: boolean }) {
const content = useCurrentBuilderResumeSelector((resume) => resume.data.summary.content);
const updateResumeData = useUpdateResumeData();
return (
<RichTextEditor
label={t`Summary`}
value={content}
disabled={locked}
hint={<Trans>2–3 sentences reads best</Trans>}
onChange={(html) =>
updateResumeData(
(draft) => {
draft.summary.content = html;
},
{ coalesceKey: "summary.content" },
)
}
/>
);
}
@@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Resume } from "@/features/resume/builder/draft";
import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { useResumeStore } from "@/features/resume/builder/draft";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { useEditorStore } from "../store";
import { WritePanel } from "./write-panel";
const routerParams = vi.hoisted(() => ({ resumeId: "write-panel" }));
const toastState = vi.hoisted(() => ({ add: vi.fn() }));
vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
setLocked: { mutationOptions: () => ({}) },
update: { call: vi.fn(() => new Promise(() => undefined)) },
},
},
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: toastState.add, close: vi.fn() } }));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en-US", messages: {} });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
});
afterEach(() => {
cleanup();
useEditorStore.getState().reset();
toastState.add.mockClear();
});
function renderPanel(edit?: (data: ResumeData) => void) {
const data = parseResumeData(structuredClone(sampleResumeData));
edit?.(data);
const resume: Resume = {
id: routerParams.resumeId,
name: "Write panel",
slug: "write-panel",
tags: [],
data,
isLocked: false,
updatedAt: new Date("2026-09-28T00:00:00.000Z"),
};
useResumeStore.getState().initialize(resume);
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<WritePanel />
</PromptDialogProvider>
</I18nProvider>
</QueryClientProvider>,
);
}
const data = () => useResumeStore.getState().resume?.data as ResumeData;
const sectionRow = (id: string) => document.getElementById(`sidebar-${id}`) as HTMLElement;
describe("WritePanel", () => {
it("lists sections in print order and opens a section's entries", () => {
renderPanel();
const experience = sectionRow("experience");
fireEvent.click(within(experience).getAllByRole("button", { name: "Experience" })[0] as HTMLElement);
expect(within(experience).getByText("Cascade Studios", { exact: false })).toBeInTheDocument();
expect(within(experience).getByRole("button", { name: "Add experience" })).toBeInTheDocument();
});
it("adds a draft entry, open and focused on its first field, that prints once it has a company", async () => {
renderPanel();
const experience = sectionRow("experience");
fireEvent.click(within(experience).getAllByRole("button", { name: "Experience" })[0] as HTMLElement);
fireEvent.click(within(experience).getByRole("button", { name: "Add experience" }));
await waitFor(() => expect(within(experience).getByText("Draft · not printed")).toBeInTheDocument());
expect(document.activeElement).toHaveAttribute("data-entry-field", "position");
fireEvent.change(within(experience).getByRole("textbox", { name: "Company" }), { target: { value: "Lumen" } });
expect(within(experience).queryByText("Draft · not printed")).not.toBeInTheDocument();
expect(data().sections.experience.items.at(-1)).toMatchObject({ company: "Lumen" });
});
it("deletes an entry at once and offers Undo", () => {
renderPanel();
const [first] = data().sections.education.items;
act(() => useEditorStore.getState().select({ kind: "item", sectionId: "education", itemId: first?.id ?? "" }));
act(() => useEditorStore.getState().setSectionOpen("education", true));
fireEvent.click(within(sectionRow("education")).getByRole("button", { name: "Delete entry" }));
expect(data().sections.education.items).toHaveLength(0);
const toast = toastState.add.mock.calls.at(-1)?.[0];
expect(toast).toMatchObject({ description: "Entry deleted", actionProps: { children: "Undo" } });
act(() => toast.actionProps.onClick());
expect(data().sections.education.items).toHaveLength(1);
});
it("moves a section with ⌥↑ on its title, changing the print order", () => {
renderPanel();
const before = data().metadata.layout.pages[0]?.main ?? [];
const index = before.indexOf("education");
const title = within(sectionRow("education")).getAllByRole("button", { name: "Education" })[0] as HTMLElement;
fireEvent.keyDown(title, { key: "ArrowUp", altKey: true });
const after = data().metadata.layout.pages[0]?.main ?? [];
expect(after.indexOf("education")).toBe(index - 1);
});
it("keeps hidden sections listed, struck through, with the eye to show them", () => {
renderPanel((draft) => {
draft.sections.skills.hidden = true;
});
fireEvent.click(within(sectionRow("skills")).getByRole("button", { name: "Show Skills on the page" }));
expect(data().sections.skills.hidden).toBe(false);
});
});
@@ -0,0 +1,101 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import {
useCurrentBuilderResumeSelector,
useCurrentResume,
useIsResumeLocked,
usePatchResume,
} from "@/features/resume/builder/draft";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useEditorStore } from "../store";
import { AddSectionMenu, StartSuggestions } from "./add-section";
import { BasicsCard } from "./basics-card";
import { getOutlineRows } from "./model";
import { Outline } from "./outline";
/**
* Write: the Basics card, then the outline of sections in print order with their entries, then Add
* section. Every field saves as you type; a locked resume shows the same panel read-only.
*/
export function WritePanel() {
const locked = useIsResumeLocked();
const locale = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.locale);
// The selector hook reads `undefined` as "no resume yet", so optional values need a fallback.
const dateFormat = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.dateFormat ?? null);
const page = useMemo(() => ({ locale, dateFormat: dateFormat ?? undefined }), [locale, dateFormat]);
const added = useEditorStore((state) => state.addedSections);
const isEmpty = useCurrentBuilderResumeSelector((resume) => getOutlineRows(resume.data, new Set(added)).length === 0);
const openDialog = useDialogStore((state) => state.openDialog);
return (
<div className="grid gap-4 p-4">
{locked && <LockedNote />}
<BasicsCard locked={locked} />
<div>
<p className="mb-1.5 flex items-baseline justify-between px-1 font-semibold text-[11px] text-ink-3 uppercase tracking-[0.08em]">
<Trans>Sections · print order</Trans>
{!locked && (
<span className="font-normal normal-case tracking-normal">
<Trans>drag · ⌥↑↓</Trans>
</span>
)}
</p>
{isEmpty && !locked ? (
<StartSuggestions onImport={() => openDialog("resume.import", undefined)} />
) : (
<Outline locked={locked} page={page} />
)}
{!locked && <AddSectionMenu />}
</div>
<p className="px-1 text-ink-3 text-xs leading-4">
<Trans>Hidden sections keep their content but aren't printed or shared. Fields save as you type.</Trans>
</p>
</div>
);
}
/** Locked resumes read as they print; Unlock is one click and reversible. */
function LockedNote() {
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { mutate: setLocked, isPending } = useMutation(orpc.resume.setLocked.mutationOptions());
const unlock = () =>
setLocked(
{ id: resume.id, isLocked: false },
{
onSuccess: () =>
patchResume((draft) => {
draft.isLocked = false;
}),
onError: (error) => toast.add({ type: "error", description: getResumeErrorMessage(error) }),
},
);
return (
<Alert variant="info" className="items-center">
<Icon name="lock" size={20} />
<AlertDescription className="flex items-center justify-between gap-3">
<span>
<Trans>Locked. Unlock to edit.</Trans>
</span>
<Button size="sm" variant="secondary" loading={isPending} onClick={unlock} aria-label={t`Unlock editing`}>
<Trans>Unlock</Trans>
</Button>
</AlertDescription>
</Alert>
);
}
@@ -45,6 +45,7 @@ vi.mock("@reactive-resume/ui/components/toast", () => ({
}));
vi.mock("../builder/draft", () => ({
isEditableElementFocused: () => false,
useResumeData: () => previewMock.builderResumeData,
usePreviewPausedStore: (selector: (state: { paused: boolean }) => unknown) => selector({ paused: false }),
}));
@@ -11,7 +11,7 @@ import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { isEditableElementFocused, usePreviewPausedStore, useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
@@ -29,6 +29,9 @@ type PreviewPdf = {
};
const UPDATE_DEBOUNCE_MS = 100;
// While a field has focus the page waits for a pause in typing, so rendering (about 230 ms for two pages,
// on the main thread) doesn't compete with keystrokes.
const TYPING_DEBOUNCE_MS = 250;
// Incoming layer fades in over the old one; the old layer holds at full opacity until the incoming one is opaque,
// then drops out. Fading both at once dips the page towards the background mid-swap.
const INCOMING_TRANSITION = { duration: 0.15, ease: EASE_OUT_STRONG };
@@ -132,7 +135,7 @@ export function ResumePreviewClient({
let cancelled = false;
const requestId = ++requestIdRef.current;
const delay = hasPreviewRef.current ? UPDATE_DEBOUNCE_MS : 0;
const delay = !hasPreviewRef.current ? 0 : isEditableElementFocused() ? TYPING_DEBOUNCE_MS : UPDATE_DEBOUNCE_MS;
const generatePdfPreview = async () => {
try {
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import { makeSectionItem } from "./make-section-item";
describe("makeSectionItem", () => {
it("clones the provided item but generates a fresh id", () => {
const original = { id: "original-id", name: "Skill" };
const result = makeSectionItem({ id: "default", name: "" }, original);
expect(result.name).toBe("Skill");
expect(result.id).not.toBe("original-id");
expect(result.id.length).toBeGreaterThan(0);
});
it("uses defaultItem when no item is provided", () => {
const defaultItem = { id: "default", name: "", level: 0 };
const result = makeSectionItem(defaultItem);
expect(result.name).toBe("");
expect(result.level).toBe(0);
expect(result.id).not.toBe("default");
});
it("does not mutate the input item when duplicating", () => {
const original = { id: "original-id", value: "test" };
const before = JSON.stringify(original);
makeSectionItem({ id: "default", value: "" }, original);
expect(JSON.stringify(original)).toBe(before);
});
it("does not mutate the defaultItem", () => {
const defaultItem = { id: "default", value: "x" };
const before = JSON.stringify(defaultItem);
makeSectionItem(defaultItem);
expect(JSON.stringify(defaultItem)).toBe(before);
});
it("generates a unique id per call", () => {
const a = makeSectionItem({ id: "default", name: "" });
const b = makeSectionItem({ id: "default", name: "" });
expect(a.id).not.toBe(b.id);
});
it("preserves all other fields when duplicating", () => {
const item = { id: "x", a: 1, b: { c: 2 }, d: [3] };
const result = makeSectionItem({ id: "default", a: 0, b: { c: 0 }, d: [] }, item);
expect(result.a).toBe(1);
expect(result.b).toEqual({ c: 2 });
expect(result.d).toEqual([3]);
});
});
@@ -1,16 +0,0 @@
import { generateId } from "@reactive-resume/utils/string";
/**
* Resolves initial values for section-item create dialogs.
*
* Expected usage:
* - Duplicate flow: pass a fully schema-valid `item`; this helper clones it and always generates a fresh `id`.
* - Create flow: pass no `item`; this helper uses `createItem` and still guarantees a fresh `id`.
*
* This helper intentionally does not deep-merge partial items. Callers should provide either a complete item
* (duplicate) or no item (create) to keep the seam explicit and predictable.
*/
export function makeSectionItem<T extends { id: string }>(defaultItem: T, item?: T): T {
if (item) return { ...item, id: generateId() };
return { ...defaultItem, id: generateId() };
}
@@ -1,106 +0,0 @@
import { describe, expect, it } from "vitest";
import { produce } from "immer";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createSectionItem, updateSectionItem } from "./section-actions";
describe("createSectionItem", () => {
it("appends to a built-in section's items array", () => {
const result = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "1", name: "Go" });
});
expect(result.sections.skills.items).toHaveLength(1);
expect((result.sections.skills.items[0] as { name?: string })?.name).toBe("Go");
});
it("appends to a custom section by id", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "custom-1",
type: "cover-letter",
title: "Cover Letter",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
});
});
const result = produce(initial, (draft) => {
createSectionItem(draft, "skills", { id: "1", text: "Custom item" }, "custom-1");
});
const customSection = result.customSections.find((s) => s.id === "custom-1");
expect(customSection?.items).toHaveLength(1);
// Built-in section is untouched
expect(result.sections.skills.items).toHaveLength(0);
});
it("does nothing when customSectionId does not match", () => {
const result = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "1", name: "x" }, "non-existent");
});
// Skills not touched, no custom section to modify
expect(result.sections.skills.items).toHaveLength(0);
});
});
describe("updateSectionItem", () => {
it("replaces the matching item in a built-in section", () => {
const initial = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "abc", name: "Go", level: 4 });
});
const result = produce(initial, (draft) => {
updateSectionItem(draft, "skills", { id: "abc", name: "Go", level: 5 });
});
const item = result.sections.skills.items[0] as { id?: string; name?: string; level?: number };
expect(item?.level).toBe(5);
});
it("does nothing when item id does not exist", () => {
const initial = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "abc", name: "Go" });
});
const before = JSON.stringify(initial.sections.skills);
const result = produce(initial, (draft) => {
updateSectionItem(draft, "skills", { id: "non-existent", name: "X" });
});
expect(JSON.stringify(result.sections.skills)).toBe(before);
});
it("replaces the matching item in a custom section", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "custom-1",
type: "cover-letter",
title: "",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "x", value: "old" } as never],
});
});
const result = produce(initial, (draft) => {
updateSectionItem(draft, "skills", { id: "x", value: "new" }, "custom-1");
});
const customSection = result.customSections.find((s) => s.id === "custom-1");
if (!customSection) throw new Error("Custom section not found");
expect((customSection.items[0] as { value?: string }).value).toBe("new");
});
it("does nothing when custom section is not found", () => {
const before = JSON.stringify(defaultResumeData);
const result = produce(defaultResumeData, (draft) => {
updateSectionItem(draft, "skills", { id: "x", value: "new" }, "non-existent");
});
expect(JSON.stringify(result)).toBe(before);
});
});
@@ -1,42 +0,0 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import type { WritableDraft } from "immer";
/**
* Pushes a new item into a section's items array.
* Handles both built-in sections and custom sections.
*/
export function createSectionItem(
draft: WritableDraft<ResumeData>,
sectionKey: SectionType,
formData: Record<string, unknown>,
customSectionId?: string,
) {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (section) section.items.push(formData as never);
} else {
(draft.sections[sectionKey].items as unknown[]).push(formData);
}
}
/**
* Finds and replaces an existing item in a section's items array by id.
* Handles both built-in sections and custom sections.
*/
export function updateSectionItem(
draft: WritableDraft<ResumeData>,
sectionKey: SectionType,
formData: { id: string } & Record<string, unknown>,
customSectionId?: string,
) {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData as never;
} else {
const items = draft.sections[sectionKey].items as Array<{ id: string }>;
const index = items.findIndex((item) => item.id === formData.id);
if (index !== -1) (items[index] as unknown as Record<string, unknown>) = formData;
}
}
-2
View File
@@ -35,8 +35,6 @@ import {
import { match } from "ts-pattern";
import { cn } from "@reactive-resume/utils/style";
export { defaultSectionIconNames } from "@reactive-resume/schema/resume/section-icons";
export type LeftSidebarSection = "picture" | "basics" | "summary" | SectionType | "custom";
// CustomSectionType values that are not in SectionType (used in custom sections only)
@@ -10,9 +10,9 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { EditorBar } from "./editor-bar";
import { ModePanel } from "./mode-panels";
import { revealSelectionInPanel } from "./reveal-selection";
import { ShareSheet } from "./share-sheet";
import { useEditorHotkeys } from "./use-editor-hotkeys";
import { useEditorMode } from "./use-editor-mode";
@@ -3,7 +3,8 @@ import { Fragment } from "react";
import { Separator } from "@reactive-resume/ui/components/separator";
import { OfflineBanner } from "@/features/resume/editor/save-status";
import { useEditorStore } from "@/features/resume/editor/store";
import { BuilderWritePanel } from "../-sidebar/left";
import { selectionFromPanelElement } from "@/features/resume/editor/write/reveal";
import { WritePanel } from "@/features/resume/editor/write/write-panel";
import { AtsCheckSectionBuilder } from "../-sidebar/right/sections/ats-check";
import { CustomStylesSectionBuilder } from "../-sidebar/right/sections/custom-styles";
import { DesignSectionBuilder } from "../-sidebar/right/sections/design";
@@ -12,7 +13,6 @@ import { PageSectionBuilder } from "../-sidebar/right/sections/page";
import { TemplateSectionBuilder } from "../-sidebar/right/sections/template";
import { TypographySectionBuilder } from "../-sidebar/right/sections/typography";
import { BareSectionChrome } from "../-sidebar/right/shared/section-base";
import { selectionFromPanelElement } from "./reveal-selection";
// Until the Design mode is rebuilt (M4), it hosts the existing design sections in this order.
const DESIGN_SECTIONS = [
@@ -48,7 +48,7 @@ function CheckPanel() {
);
}
function WritePanel() {
function WriteMode() {
const select = useEditorStore((state) => state.select);
return (
@@ -56,11 +56,13 @@ function WritePanel() {
// biome-ignore lint/a11y/noStaticElementInteractions: not an interactive element, see above.
<div
onFocus={(event) => {
// Fields only: buttons (an entry's title, the chevrons) change the selection themselves.
if (!event.target.matches("input, textarea, select, [contenteditable='true']")) return;
const selection = selectionFromPanelElement(event.target);
if (selection) select(selection);
}}
>
<BuilderWritePanel />
<WritePanel />
</div>
);
}
@@ -69,7 +71,7 @@ export function ModePanel({ mode }: { mode: EditorMode }) {
return (
<>
<OfflineBanner className="mx-4 mt-4 w-auto" />
{mode === "design" ? <DesignPanel /> : mode === "check" ? <CheckPanel /> : <WritePanel />}
{mode === "design" ? <DesignPanel /> : mode === "check" ? <CheckPanel /> : <WriteMode />}
</>
);
}
@@ -9,8 +9,8 @@ import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector } from "@/features/resume/builder/draft";
import { PageOverlay } from "@/features/resume/editor/page-overlay";
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { ResumePreview } from "@/features/resume/preview/preview";
import { revealSelectionInPanel } from "./reveal-selection";
import { useEditorMode } from "./use-editor-mode";
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
@@ -1,54 +0,0 @@
import type { EditorSelection } from "@/features/resume/editor/store";
import type { LeftSidebarSection } from "@/libs/resume/section";
import {
focusCustomSidebarSection,
focusLeftSidebarSection,
getScrollBehavior,
} from "@/features/resume/builder/section-recovery";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { leftSidebarSections } from "@/libs/resume/section";
import { useSectionStore } from "../-store/section";
const isBuiltInSection = (sectionId: string): sectionId is LeftSidebarSection =>
(leftSidebarSections as readonly string[]).includes(sectionId);
const SECTION_ANCHOR = "sidebar-";
const ITEM_ANCHOR = atsFindingItemElementId("");
/**
* The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing
* a field outlines its block on the page. Picture and Basics edit the header.
*/
export function selectionFromPanelElement(element: Element): EditorSelection | null {
const sectionId = element.closest(`[id^="${SECTION_ANCHOR}"]`)?.id.slice(SECTION_ANCHOR.length);
// "sidebar-hidden-…" rows restore hidden sections; they have no block on the page.
if (!sectionId || sectionId.startsWith("hidden-")) return null;
if (sectionId === "basics" || sectionId === "picture") return { kind: "header" };
const itemId = element.closest(`[id^="${ITEM_ANCHOR}"]`)?.id.slice(ITEM_ANCHOR.length);
return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId };
}
/**
* Brings the selected block's editor into view in the Write panel: expands its section and scrolls the
* entry to the middle. The Write panel is rebuilt in M3; until then this targets the existing section list.
*/
export function revealSelectionInPanel(selection: EditorSelection) {
const sectionKey: LeftSidebarSection =
selection.kind === "header" ? "basics" : isBuiltInSection(selection.sectionId) ? selection.sectionId : "custom";
useSectionStore.getState().setCollapsed(sectionKey, false);
requestAnimationFrame(() => {
if (selection.kind === "item") {
const row = document.getElementById(atsFindingItemElementId(selection.itemId));
if (row) {
row.scrollIntoView({ block: "center", behavior: getScrollBehavior() });
return;
}
}
if (selection.kind === "header") focusLeftSidebarSection("basics");
else if (isBuiltInSection(selection.sectionId)) focusLeftSidebarSection(selection.sectionId);
else focusCustomSidebarSection(selection.sectionId);
});
}
@@ -1,94 +0,0 @@
import type { LeftSidebarSection } from "@/libs/resume/section";
import { Trans } from "@lingui/react/macro";
import { LockSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { toast } from "@reactive-resume/ui/components/toast";
import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
import { SectionEditorList } from "@/features/resume/builder/section-recovery";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { BasicsSectionBuilder } from "./sections/basics";
import { CustomSectionBuilder } from "./sections/custom";
import { PictureSectionBuilder } from "./sections/picture";
import { SummarySectionBuilder } from "./sections/summary";
import { ItemsSection } from "./shared/items-section";
function getSectionComponent(type: LeftSidebarSection) {
return match(type)
.with("picture", () => <PictureSectionBuilder />)
.with("basics", () => <BasicsSectionBuilder />)
.with("summary", () => <SummarySectionBuilder />)
.with(
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
(type) => <ItemsSection type={type} />,
)
.with("custom", () => <CustomSectionBuilder />)
.exhaustive();
}
/** The Write mode panel: the section editors. It's rebuilt as the outline in M3. */
export function BuilderWritePanel() {
const isLocked = useIsResumeLocked();
return (
<div className="@container space-y-4 p-4">
{isLocked && <LockBanner />}
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
<SectionEditorList renderSection={getSectionComponent} />
</fieldset>
</div>
);
}
function LockBanner() {
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { mutate: setLocked, isPending } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUnlock = () => {
setLocked(
{ id: resume.id, isLocked: false },
{
onSuccess: () => {
patchResume((draft) => {
draft.isLocked = false;
});
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
},
},
);
};
return (
<div className="flex items-center gap-x-3 rounded-md border border-warn/40 bg-warn-soft p-3">
<LockSimpleIcon className="size-5 shrink-0 text-warn-text" />
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">
<Trans>This resume is locked</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>Editing is disabled until you unlock it.</Trans>
</p>
</div>
<Button size="sm" variant="secondary" disabled={isPending} onClick={handleUnlock}>
<Trans>Enable editing</Trans>
</Button>
</div>
);
}
@@ -1,202 +0,0 @@
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { basicsSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { URLInput } from "@/components/input/url-input";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
import { CustomFieldsSection } from "./custom-fields";
export function BasicsSectionBuilder() {
return (
<SectionBase type="basics">
<BasicsSectionForm />
</SectionBase>
);
}
const formSchema = basicsSchema;
type FormValues = z.infer<typeof formSchema>;
function BasicsSectionForm() {
const basics = useCurrentBuilderResumeSelector((resume) => resume.data.basics);
const updateResumeData = useUpdateResumeData();
const persist = (data: FormValues) => {
updateResumeData((draft) => {
draft.basics = data;
});
};
const form = useAppForm({
defaultValues: basics,
validators: { onChange: formSchema },
// Persist on every field change via a form-level listener. Previously each field called
// `form.handleSubmit()` on change, which re-validated the whole form AND toggled submit state —
// firing the render cascade twice per keystroke. A listener persists once, without the submit churn.
listeners: {
onChange: ({ formApi }) => {
persist(formApi.state.values);
},
},
onSubmit: ({ value }) => {
persist(value);
},
});
useSyncFormValues(form, basics);
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="headline">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Headline</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl
render={
<Input
type="email"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="phone">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Phone</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="location">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl
render={
<URLInput
name={field.name}
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<CustomFieldsSection form={form} />
</form>
);
}
@@ -1,172 +0,0 @@
import type { basicsSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
import { Reorder, useDragControls } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { generateId } from "@reactive-resume/utils/string";
import { IconPicker } from "@/components/input/icon-picker";
import { withForm } from "@/libs/tanstack-form";
type FormValues = z.infer<typeof basicsSchema>;
type CustomField = FormValues["customFields"][number];
const defaultValues: FormValues = {
name: "",
headline: "",
email: "",
phone: "",
location: "",
website: { url: "", label: "" },
customFields: [],
};
export const CustomFieldsSection = withForm({
defaultValues,
render: ({ form }) => {
return (
<form.Field name="customFields" mode="array">
{(customFieldsField) => (
<Reorder.Group
className="space-y-4"
values={customFieldsField.state.value}
onReorder={(fields) => {
customFieldsField.setValue(fields);
}}
>
{customFieldsField.state.value.map((field: CustomField, index: number) => (
<CustomFieldItem key={field.id} field={field}>
<form.Field name={`customFields[${index}].icon`}>
{(iconField) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
name={iconField.name}
value={iconField.state.value}
className="rounded-r-none! border-e-0!"
onChange={(icon) => {
iconField.handleChange(icon);
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name={`customFields[${index}].text`}>
{(textField) => (
<FormItem className="flex-1">
<FormControl
render={
<Input
name={textField.name}
value={textField.state.value}
className="rounded-l-none!"
onChange={(e) => {
textField.handleChange(e.target.value);
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name={`customFields[${index}].link`}>
{(linkField) => (
<Popover>
<PopoverTrigger
render={
<Button size="icon" variant="ghost" aria-label={t`Add link`} className="ms-1">
<LinkIcon />
</Button>
}
/>
<PopoverContent align="center">
<div className="flex flex-col gap-y-1.5">
<Label htmlFor={linkField.name} className="text-muted-foreground text-xs">
<Trans>Enter the URL to link to</Trans>
</Label>
<Input
type="url"
value={linkField.state.value}
id={linkField.name}
placeholder={t({
comment: "Placeholder text for custom link URL field in resume builder",
message: "Must start with https://",
})}
onChange={(e) => {
linkField.handleChange(e.target.value);
}}
/>
</div>
</PopoverContent>
</Popover>
)}
</form.Field>
<Button
size="icon"
variant="ghost"
aria-label={t`Remove custom field`}
onClick={() => {
customFieldsField.removeValue(index);
}}
>
<XIcon />
</Button>
</CustomFieldItem>
))}
<Button
variant="ghost"
onClick={() => {
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
}}
>
<ListPlusIcon />
<Trans>Add a custom field</Trans>
</Button>
</Reorder.Group>
)}
</form.Field>
);
},
});
type CustomFieldItemProps = {
field: CustomField;
children: React.ReactNode;
};
function CustomFieldItem({ field, children }: CustomFieldItemProps) {
const controls = useDragControls();
return (
<Reorder.Item value={field} dragListener={false} dragControls={controls} className="flex items-center">
<Button
size="icon"
variant="ghost"
aria-label={t`Reorder custom field`}
className="me-2 touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<DotsSixVerticalIcon />
</Button>
{children}
</Reorder.Item>
);
}
@@ -1,305 +0,0 @@
import type {
CustomSection,
CustomSectionItem as CustomSectionItemType,
CustomSectionType,
} from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import {
ColumnsIcon,
CopySimpleIcon,
DotsThreeVerticalIcon,
EyeClosedIcon,
EyeIcon,
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { stripHtml } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getSectionTitle } from "@/libs/resume/section";
import { SECTIONS, SectionItemList } from "../shared/items-section";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton } from "../shared/section-item";
import { SkillKeywordLayoutMenu } from "../shared/skill-keyword-layout-menu";
function truncateHtml(html: string, max = 50): string {
const stripped = stripHtml(html);
return stripped.length > max ? `${stripped.slice(0, max)}...` : stripped;
}
function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): string {
if (type === "summary") {
if ("content" in item) {
return (
truncateHtml(item.content) ||
t({
comment: "Fallback title for a custom summary item in resume builder when content is empty",
message: "Summary",
})
);
}
return t({
comment: "Fallback title for a custom summary item in resume builder when content is unavailable",
message: "Summary",
});
}
if (type === "cover-letter") {
if ("recipient" in item) {
return (
truncateHtml(item.recipient) ||
t({
comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty",
message: "Cover Letter",
})
);
}
return t({
comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable",
message: "Cover Letter",
});
}
return SECTIONS[type].title(item as never);
}
function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined {
if (type === "cover-letter") {
if ("content" in item) {
const result = truncateHtml(item.content);
return result || undefined;
}
return undefined;
}
if (type === "summary") return undefined;
return SECTIONS[type].subtitle?.(item as never) || undefined;
}
export function CustomSectionBuilder() {
const customSections = useCurrentBuilderResumeSelector((resume) => resume.data.customSections);
return (
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
{customSections
.filter((section) => !section.hidden)
.map((section) => (
<CustomSectionContainer key={section.id} section={section} />
))}
{/* Add Custom Section Button */}
<SectionAddItemButton type="custom" variant="secondary" className="rounded-md">
<Trans>Add a new custom section</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
type CustomSectionContainerProps = {
section: CustomSection;
};
function CustomSectionContainer({ section }: CustomSectionContainerProps) {
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const handleReorder = (items: CustomSectionItemType[]) => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].items = items;
});
};
return (
<div id={`sidebar-${section.id}`} className="rounded-md border">
{/* Section Header */}
<div className="group flex select-none">
<button
type="button"
onClick={onUpdateSection}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-start transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
section.hidden && "opacity-50",
)}
>
<Badge variant="neutral" className="mb-1.5 rounded-md">
{getSectionTitle(section.type)}
</Badge>
<span className="line-clamp-1 text-wrap font-medium text-base">{section.title}</span>
<span className="text-muted-foreground text-xs">
<Plural value={section.items.length} one="# item" other="# items" />
</span>
</button>
<CustomSectionDropdownMenu section={section} />
</div>
{/* Section Items */}
{section.items.length > 0 && (
<div className={cn("border-t", section.hidden && "opacity-50")}>
<SectionItemList
type={section.type}
items={section.items}
onReorder={handleReorder}
customSectionId={section.id}
getTitle={(item) => getItemTitle(section.type, item)}
getSubtitle={(item) => getItemSubtitle(section.type, item)}
/>
</div>
)}
{/* Add Item Button */}
<div className="border-t">
<SectionAddItemButton type={section.type} customSectionId={section.id}>
<Trans>Add a new item</Trans>
</SectionAddItemButton>
</div>
</div>
);
}
type CustomSectionDropdownMenuProps = {
section: CustomSection;
};
function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const showHeading = section.showHeading !== false;
const onToggleSectionVisibility = () => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].hidden = !draft.customSections[sectionIndex].hidden;
});
};
const onToggleHeading = () => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].showHeading = !(draft.customSections[sectionIndex].showHeading !== false);
});
};
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const onDuplicateSection = () => {
openDialog("resume.sections.custom.create", section);
};
const onSetColumns = (value: string) => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].columns = Number.parseInt(value, 10);
});
};
const onDeleteSection = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, {
confirmText: t({
comment: "Destructive confirmation button label when deleting a custom section in resume builder",
message: "Delete",
}),
cancelText: t({
comment: "Confirmation dialog button label to abort deleting a custom section in resume builder",
message: "Cancel",
}),
});
if (!confirmed) return;
updateResumeData((draft) => {
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
...page,
main: page.main.filter((id) => id !== section.id),
sidebar: page.sidebar.filter((id) => id !== section.id),
}));
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label={t`Section options`}>
<DotsThreeVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onClick={onToggleSectionVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleHeading}>
{showHeading ? <EyeClosedIcon /> : <EyeIcon />}
{showHeading ? <Trans>Hide heading</Trans> : <Trans>Show heading</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onUpdateSection}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicateSection}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
{section.type === "skills" && <SkillKeywordLayoutMenu sectionId={section.id} />}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={column.toString()}>
<Plural value={column} one="# Column" other="# Columns" />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={onDeleteSection}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,149 +0,0 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const educationItems = vi.hoisted(() => [
{
id: "e1",
school: "MIT",
degree: "BS",
area: "CS",
grade: "",
location: "",
period: "2010-2014",
description: "",
hidden: false,
website: { url: "", label: "", inlineLink: false },
},
]);
const experienceItems = vi.hoisted(() => [
// Experience with position set
{
id: "x1",
company: "Acme",
position: "Senior Engineer",
location: "",
period: "2020-2024",
description: "",
hidden: false,
website: { url: "", label: "", inlineLink: false },
roles: [],
},
// Experience with empty position and multiple roles → falls back to "N roles"
{
id: "x2",
company: "BetaCo",
position: "",
location: "",
period: "2015-2020",
description: "",
hidden: false,
website: { url: "", label: "", inlineLink: false },
roles: [
{ position: "Engineer", period: "" },
{ position: "Lead", period: "" },
],
},
// Experience with empty position and one role → "1 role"
{
id: "x3",
company: "Gamma",
position: "",
location: "",
period: "2014",
description: "",
hidden: false,
website: { url: "", label: "", inlineLink: false },
roles: [{ position: "Intern", period: "" }],
},
]);
type SectionBaseProps = {
children: React.ReactNode;
};
type SectionAddItemButtonProps = {
children: React.ReactNode;
};
type SectionItemProps = {
title: string;
subtitle: string;
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: {
sections: {
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
},
},
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
vi.mock("../shared/section-item", () => ({
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
SectionItem: ({ title, subtitle }: SectionItemProps) => (
<div>
<span data-testid="item-title">{title}</span>
<span data-testid="item-subtitle">{subtitle}</span>
</div>
),
}));
const { ItemsSection } = await import("../shared/items-section");
const EducationSectionBuilder = () => <ItemsSection type="education" />;
const ExperienceSectionBuilder = () => <ItemsSection type="experience" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const wrap = (ui: React.ReactNode) => <I18nProvider i18n={i18n}>{ui}</I18nProvider>;
describe("EducationSectionBuilder", () => {
it("maps school → title and degree → subtitle", () => {
render(wrap(<EducationSectionBuilder />));
expect(screen.getByTestId("item-title").textContent).toBe("MIT");
expect(screen.getByTestId("item-subtitle").textContent).toBe("BS");
});
it("renders the Add a new education affordance", () => {
render(wrap(<EducationSectionBuilder />));
expect(screen.getByRole("button", { name: "Add a new education" })).toBeInTheDocument();
});
});
describe("ExperienceSectionBuilder", () => {
it("uses position as subtitle when present", () => {
render(wrap(<ExperienceSectionBuilder />));
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
expect(subtitles[0]).toBe("Senior Engineer");
});
it("falls back to 'N roles' when position is empty and multiple roles exist", () => {
render(wrap(<ExperienceSectionBuilder />));
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
expect(subtitles[1]).toBe("2 roles");
});
it("uses '1 role' (singular) for a single role entry", () => {
render(wrap(<ExperienceSectionBuilder />));
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
expect(subtitles[2]).toBe("1 role");
});
it("renders the Add a new experience affordance", () => {
render(wrap(<ExperienceSectionBuilder />));
expect(screen.getByRole("button", { name: "Add a new experience" })).toBeInTheDocument();
});
});
@@ -1,168 +0,0 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { BasicsSectionBuilder } from "./basics";
import { PictureSectionBuilder } from "./picture";
const state = vi.hoisted(() => ({ data: {} as ResumeData, update: vi.fn(), uploadFile: vi.fn() }));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (selector: (resume: { data: ResumeData }) => unknown) => selector(state),
useUpdateResumeData: () => state.update,
}));
vi.mock("@/libs/tanstack-form", async () => {
const { useForm } = await import("@tanstack/react-form");
return { useAppForm: useForm };
});
vi.mock("@/libs/orpc/client", () => ({
orpc: {
storage: {
uploadFile: { mutationOptions: () => ({ mutationFn: state.uploadFile }) },
deleteFile: { mutationOptions: () => ({ mutationFn: vi.fn() }) },
},
},
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("./custom-fields", () => ({ CustomFieldsSection: () => null }));
vi.mock("@/components/input/color-picker", () => ({ ColorPicker: () => null }));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
state.data = structuredClone(defaultResumeData);
state.update.mockReset();
state.update.mockImplementation((update: (draft: ResumeData) => void) => update(state.data));
state.uploadFile.mockReset();
state.uploadFile.mockResolvedValue({ url: "/uploads/picture.png" });
});
function renderSection(children: ReactNode) {
return render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>{children}</I18nProvider>
</QueryClientProvider>,
);
}
describe("builder field labels", () => {
it("names and focuses the Website input while preserving URL edits", async () => {
const user = userEvent.setup();
renderSection(<BasicsSectionBuilder />);
const input = screen.getByRole("textbox", { name: "Website" });
expect(screen.getByLabelText("Website")).toBe(input);
await user.click(screen.getByText("Website", { selector: "label" }));
expect(input).toHaveFocus();
await user.type(input, "example.com/profile");
await waitFor(() => expect(state.data.basics.website.url).toBe("https://example.com/profile"));
});
it("names and focuses Picture Size while preserving numeric edits", async () => {
const user = userEvent.setup();
renderSection(<PictureSectionBuilder />);
const input = screen.getByRole("spinbutton", { name: "Size" });
expect(screen.getByLabelText("Size")).toBe(input);
await user.click(screen.getByText("Size", { selector: "label" }));
expect(input).toHaveFocus();
fireEvent.change(input, { target: { value: "144" } });
await waitFor(() => expect(state.data.picture.size).toBe(144));
});
it("persists named fit choices and previews contain without cropping", async () => {
const user = userEvent.setup();
renderSection(<PictureSectionBuilder />);
expect(screen.getByRole("group", { name: "Fit" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Contain" }));
await waitFor(() => expect(state.data.picture.fit).toBe("contain"));
const file = new File(["full-image"], "full.png", { type: "image/png" });
await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file);
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce());
expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file);
const preview = screen.getByRole("button", { name: "Delete picture" }).querySelector("img");
expect(preview).toBeInTheDocument();
expect(getComputedStyle(preview as HTMLImageElement).objectFit).toBe("contain");
expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument();
});
it("keeps cover uploads in cancelable crop flow", async () => {
const user = userEvent.setup();
renderSection(<PictureSectionBuilder />);
const file = new File(["crop-image"], "crop.png", { type: "image/png" });
await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file);
const dialog = screen.getByRole("dialog", { name: "Crop picture" });
expect(dialog).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(dialog).not.toBeInTheDocument();
expect(state.uploadFile).not.toHaveBeenCalled();
});
it("uploads the original file when cropping is skipped", async () => {
const user = userEvent.setup();
renderSection(<PictureSectionBuilder />);
const file = new File(["original-image"], "original.png", { type: "image/png" });
await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file);
const dialog = screen.getByRole("dialog", { name: "Crop picture" });
await user.click(within(dialog).getByRole("button", { name: "Skip and Upload" }));
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce());
expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file);
expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument();
});
it("retries the same full contain file after an upload error", async () => {
state.uploadFile.mockRejectedValueOnce(new Error("Upload failed"));
const user = userEvent.setup();
renderSection(<PictureSectionBuilder />);
await user.click(screen.getByRole("button", { name: "Contain" }));
const file = new File(["full-image"], "full.png", { type: "image/png" });
const input = screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement;
await user.upload(input, file);
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce());
expect(state.data.picture.url).toBe("");
expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument();
await waitFor(() => expect(input.files).toHaveLength(0));
await user.upload(input, file);
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledTimes(2));
expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file);
expect(state.uploadFile.mock.calls[1]?.[0]).toBe(file);
});
it("disables fit and upload controls inside the builder lock fieldset", () => {
renderSection(
<fieldset disabled>
<PictureSectionBuilder />
</fieldset>,
);
expect(screen.getByRole("button", { name: "Contain" })).toBeDisabled();
expect(screen.getAllByLabelText("Upload picture")[0]).toBeDisabled();
});
});
@@ -1,205 +0,0 @@
// @vitest-environment happy-dom
//
// Bulk-cover the small reorderable section components — they all share the same
// shape: render a SectionItem per data row with title/subtitle mapped to specific
// fields, plus an "Add a new X" button. Test them together to amortize the mock setup.
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const sections = vi.hoisted(() => ({
awards: [
{
id: "a1",
title: "Hackathon Winner",
awarder: "Acme",
date: "2024",
hidden: false,
description: "",
website: { url: "", label: "", inlineLink: false },
},
],
certifications: [
{
id: "c1",
title: "AWS Solutions Architect",
issuer: "AWS",
date: "2024",
hidden: false,
description: "",
website: { url: "", label: "", inlineLink: false },
},
],
interests: [{ id: "i1", name: "Cooking", keywords: [], hidden: false, icon: "", iconColor: "" }],
languages: [{ id: "l1", language: "English", fluency: "Native", level: 5, hidden: false }],
publications: [
{
id: "p1",
title: "On Type Systems",
publisher: "ACM",
date: "2024",
hidden: false,
description: "",
website: { url: "", label: "", inlineLink: false },
},
],
references: [
{
id: "r1",
name: "Bob Smith",
position: "Manager",
phone: "",
hidden: false,
description: "",
website: { url: "", label: "", inlineLink: false },
},
],
volunteer: [
{
id: "v1",
organization: "Code for Good",
position: "Mentor",
location: "Berlin",
period: "2022",
hidden: false,
description: "",
website: { url: "", label: "", inlineLink: false },
summary: "",
},
],
}));
type SectionBaseProps = {
children: React.ReactNode;
className?: string;
};
type SectionAddItemButtonProps = {
children: React.ReactNode;
};
type SectionItemProps = {
title: string;
subtitle: string;
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: {
sections: {
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
references: { title: "References", columns: 1, hidden: false, items: sections.references },
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
},
},
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children, className }: SectionBaseProps) => (
<div className={className} data-testid="section-base">
{children}
</div>
),
}));
vi.mock("../shared/section-item", () => ({
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => (
<button type="button" data-testid="add-button">
{children}
</button>
),
SectionItem: ({ title, subtitle }: SectionItemProps) => (
<div>
<span data-testid="item-title">{title}</span>
<span data-testid="item-subtitle">{subtitle}</span>
</div>
),
}));
const { ItemsSection } = await import("../shared/items-section");
const AwardsSectionBuilder = () => <ItemsSection type="awards" />;
const CertificationsSectionBuilder = () => <ItemsSection type="certifications" />;
const InterestsSectionBuilder = () => <ItemsSection type="interests" />;
const LanguagesSectionBuilder = () => <ItemsSection type="languages" />;
const PublicationsSectionBuilder = () => <ItemsSection type="publications" />;
const ReferencesSectionBuilder = () => <ItemsSection type="references" />;
const VolunteerSectionBuilder = () => <ItemsSection type="volunteer" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const wrap = (ui: React.ReactNode) => <I18nProvider i18n={i18n}>{ui}</I18nProvider>;
describe("left sidebar section builders — title/subtitle mapping", () => {
const cases = [
{
name: "awards",
Component: AwardsSectionBuilder,
title: "Hackathon Winner",
subtitle: "Acme",
addCopy: "Add a new award",
},
{
name: "certifications",
Component: CertificationsSectionBuilder,
title: "AWS Solutions Architect",
subtitle: "AWS • 2024",
addCopy: "Add a new certification",
},
{
name: "interests",
Component: InterestsSectionBuilder,
title: "Cooking",
subtitle: "",
addCopy: "Add a new interest",
},
{
name: "languages",
Component: LanguagesSectionBuilder,
title: "English",
subtitle: "Native",
addCopy: "Add a new language",
},
{
name: "publications",
Component: PublicationsSectionBuilder,
title: "On Type Systems",
subtitle: "ACM",
addCopy: "Add a new publication",
},
{
name: "references",
Component: ReferencesSectionBuilder,
title: "Bob Smith",
subtitle: "",
addCopy: "Add a new reference",
},
{
name: "volunteer",
Component: VolunteerSectionBuilder,
title: "Code for Good",
subtitle: "Berlin",
addCopy: "Add a new volunteer experience",
},
] as const;
for (const { name, Component, title, subtitle, addCopy } of cases) {
it(`${name}: maps fields and renders the add button`, () => {
const { unmount } = render(wrap(<Component />));
expect(screen.getByTestId("item-title").textContent).toBe(title);
expect(screen.getByTestId("item-subtitle").textContent ?? "").toBe(subtitle);
expect(screen.getByRole("button", { name: addCopy })).toBeInTheDocument();
unmount();
});
}
});
@@ -1,111 +0,0 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const sectionItems = vi.hoisted(() => [
{
id: "p1",
hidden: false,
network: "GitHub",
username: "jane",
icon: "github",
iconColor: "",
website: { url: "", label: "", inlineLink: false },
},
{
id: "p2",
hidden: false,
network: "LinkedIn",
username: "jane-doe",
icon: "linkedin",
iconColor: "",
website: { url: "", label: "", inlineLink: false },
},
]);
type SectionBaseProps = {
children: React.ReactNode;
className?: string;
};
type SectionAddItemButtonProps = {
children: React.ReactNode;
};
type SectionItemProps = {
title: string;
subtitle: string;
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: {
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
},
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children, className }: SectionBaseProps) => (
<div className={className} data-testid="section-base">
{children}
</div>
),
}));
vi.mock("../shared/section-item", () => ({
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
SectionItem: ({ title, subtitle }: SectionItemProps) => (
<div>
<span data-testid="item-title">{title}</span>
<span data-testid="item-subtitle">{subtitle}</span>
</div>
),
}));
const { ItemsSection } = await import("../shared/items-section");
const ProfilesSectionBuilder = () => <ItemsSection type="profiles" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("ProfilesSectionBuilder", () => {
it("renders one SectionItem per profile with network as title and username as subtitle", () => {
render(
<I18nProvider i18n={i18n}>
<ProfilesSectionBuilder />
</I18nProvider>,
);
const titles = screen.getAllByTestId("item-title").map((el) => el.textContent);
expect(titles).toEqual(["GitHub", "LinkedIn"]);
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
expect(subtitles).toEqual(["jane", "jane-doe"]);
});
it("renders the 'Add a new profile' affordance", () => {
render(
<I18nProvider i18n={i18n}>
<ProfilesSectionBuilder />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Add a new profile" })).toBeInTheDocument();
});
it("uses non-dashed border when items are present", () => {
render(
<I18nProvider i18n={i18n}>
<ProfilesSectionBuilder />
</I18nProvider>,
);
const wrapper = screen.getByTestId("section-base");
expect(wrapper.className).toContain("border");
expect(wrapper.className).not.toContain("border-dashed");
});
});
@@ -1,120 +0,0 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const items = vi.hoisted(() => [
// Both period and website label present → "2024 • Site"
{
id: "p1",
name: "Open CLI",
period: "2024",
hidden: false,
description: "",
website: { url: "https://example.com", label: "Site", inlineLink: false },
},
// Only period
{
id: "p2",
name: "Plugin",
period: "2023",
hidden: false,
description: "",
website: { url: "", label: "", inlineLink: false },
},
// No period, no label
{
id: "p3",
name: "Drafts",
period: "",
hidden: false,
description: "",
website: { url: "", label: " ", inlineLink: false },
},
]);
type SectionBaseProps = {
children: React.ReactNode;
};
type SectionAddItemButtonProps = {
children: React.ReactNode;
};
type SectionItemProps = {
title: string;
subtitle?: string;
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
vi.mock("../shared/section-item", () => ({
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
SectionItem: ({ title, subtitle }: SectionItemProps) => (
<div>
<span data-testid="item-title">{title}</span>
<span data-testid="item-subtitle">{subtitle ?? "<undefined>"}</span>
</div>
),
}));
const { ItemsSection } = await import("../shared/items-section");
const ProjectsSectionBuilder = () => <ItemsSection type="projects" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("ProjectsSectionBuilder buildSubtitle", () => {
it("joins period and website label with ' • ' when both are present", () => {
render(
<I18nProvider i18n={i18n}>
<ProjectsSectionBuilder />
</I18nProvider>,
);
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
expect(subtitles[0]).toBe("2024 • Site");
});
it("uses just the period when no website label is set", () => {
render(
<I18nProvider i18n={i18n}>
<ProjectsSectionBuilder />
</I18nProvider>,
);
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
expect(subtitles[1]).toBe("2023");
});
it("returns undefined when neither period nor a non-blank website label is provided", () => {
render(
<I18nProvider i18n={i18n}>
<ProjectsSectionBuilder />
</I18nProvider>,
);
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
// Mock stub renders the literal string "<undefined>" when subtitle was undefined.
expect(subtitles[2]).toBe("<undefined>");
});
it("renders the Add a new project affordance", () => {
render(
<I18nProvider i18n={i18n}>
<ProjectsSectionBuilder />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Add a new project" })).toBeInTheDocument();
});
});
@@ -1,79 +0,0 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const sectionItems = vi.hoisted(() => [
{ id: "s1", name: "TypeScript", proficiency: "Expert", level: 5, keywords: [], description: "", hidden: false },
{ id: "s2", name: "Go", proficiency: "Intermediate", level: 3, keywords: [], description: "", hidden: false },
]);
type SectionBaseProps = {
children: React.ReactNode;
className?: string;
};
type SectionAddItemButtonProps = {
children: React.ReactNode;
};
type SectionItemProps = {
title: string;
subtitle: string;
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children, className }: SectionBaseProps) => (
<div className={className} data-testid="section-base">
{children}
</div>
),
}));
vi.mock("../shared/section-item", () => ({
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
SectionItem: ({ title, subtitle }: SectionItemProps) => (
<div>
<span data-testid="item-title">{title}</span>
<span data-testid="item-subtitle">{subtitle}</span>
</div>
),
}));
const { ItemsSection } = await import("../shared/items-section");
const SkillsSectionBuilder = () => <ItemsSection type="skills" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("SkillsSectionBuilder", () => {
it("renders one SectionItem per skill, mapping name → title and proficiency → subtitle", () => {
render(
<I18nProvider i18n={i18n}>
<SkillsSectionBuilder />
</I18nProvider>,
);
expect(screen.getAllByTestId("item-title").map((el) => el.textContent)).toEqual(["TypeScript", "Go"]);
expect(screen.getAllByTestId("item-subtitle").map((el) => el.textContent)).toEqual(["Expert", "Intermediate"]);
});
it("renders an Add a new skill affordance", () => {
render(
<I18nProvider i18n={i18n}>
<SkillsSectionBuilder />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Add a new skill" })).toBeInTheDocument();
});
});
@@ -1,20 +0,0 @@
import { RichInput } from "@/components/input/rich-input";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
export function SummarySectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.summary);
const updateResumeData = useUpdateResumeData();
const onChange = (value: string) => {
updateResumeData((draft) => {
draft.summary.content = value;
});
};
return (
<SectionBase type="summary">
<RichInput value={section.content} onChange={onChange} />
</SectionBase>
);
}
@@ -1,147 +0,0 @@
import type {
CustomSectionItem,
CustomSectionType,
SectionItem as SectionItemType,
SectionType,
} from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "./section-base";
import { SectionAddItemButton, SectionItem } from "./section-item";
type SectionItemListProps<T extends CustomSectionItem | SectionItemType> = {
type: CustomSectionType;
items: T[];
onReorder: (items: T[]) => void;
getTitle: (item: T) => string;
getSubtitle?: (item: T) => string | undefined;
customSectionId?: string;
};
export function SectionItemList<T extends CustomSectionItem | SectionItemType>({
type,
items,
onReorder,
getTitle,
getSubtitle,
customSectionId,
}: SectionItemListProps<T>) {
return (
// popLayout pops exiting rows out of flow, so the group must be the positioned ancestor.
<Reorder.Group axis="y" values={items} onReorder={onReorder} className="relative">
<AnimatePresence initial={false} mode="popLayout">
{items.map((item) => (
<SectionItem
key={item.id}
type={type}
item={item}
customSectionId={customSectionId}
title={getTitle(item)}
subtitle={getSubtitle?.(item)}
/>
))}
</AnimatePresence>
</Reorder.Group>
);
}
type ItemsSectionConfig<T extends SectionType> = {
title: (item: SectionItemType<T>) => string;
subtitle?: (item: SectionItemType<T>) => string | undefined;
addLabel: ReactNode;
};
export const SECTIONS: { [T in SectionType]: ItemsSectionConfig<T> } = {
profiles: {
title: (item) => item.network,
subtitle: (item) => item.username,
addLabel: <Trans>Add a new profile</Trans>,
},
experience: {
title: (item) => item.company,
subtitle: (item) => item.position || plural(item.roles.length, { one: "# role", other: "# roles" }),
addLabel: <Trans>Add a new experience</Trans>,
},
education: {
title: (item) => item.school,
subtitle: (item) => item.degree,
addLabel: <Trans>Add a new education</Trans>,
},
projects: {
title: (item) => item.name,
subtitle: (item) => [item.period, item.website.label].filter((part) => part?.trim()).join(" • ") || undefined,
addLabel: <Trans>Add a new project</Trans>,
},
skills: {
title: (item) => item.name,
subtitle: (item) => item.proficiency,
addLabel: <Trans>Add a new skill</Trans>,
},
languages: {
title: (item) => item.language,
subtitle: (item) => item.fluency,
addLabel: <Trans>Add a new language</Trans>,
},
interests: {
title: (item) => item.name,
addLabel: <Trans>Add a new interest</Trans>,
},
awards: {
title: (item) => item.title,
subtitle: (item) => item.awarder,
addLabel: <Trans>Add a new award</Trans>,
},
certifications: {
title: (item) => item.title,
subtitle: (item) => [item.issuer, item.date].filter(Boolean).join(" • ") || undefined,
addLabel: <Trans>Add a new certification</Trans>,
},
publications: {
title: (item) => item.title,
subtitle: (item) => item.publisher,
addLabel: <Trans>Add a new publication</Trans>,
},
volunteer: {
title: (item) => item.organization,
subtitle: (item) => item.location,
addLabel: <Trans>Add a new volunteer experience</Trans>,
},
references: {
title: (item) => item.name,
addLabel: <Trans>Add a new reference</Trans>,
},
};
type ItemsSectionProps<T extends SectionType> = {
type: T;
};
export function ItemsSection<T extends SectionType>({ type }: ItemsSectionProps<T>) {
const items = useCurrentBuilderResumeSelector((resume) => resume.data.sections[type].items) as SectionItemType<T>[];
const updateResumeData = useUpdateResumeData();
const config = SECTIONS[type];
const handleReorder = (nextItems: SectionItemType<T>[]) => {
updateResumeData((draft) => {
(draft.sections[type] as { items: SectionItemType<T>[] }).items = nextItems;
});
};
return (
<SectionBase type={type} className={cn("rounded-md border", items.length === 0 && "border-dashed")}>
<SectionItemList
type={type}
items={items}
onReorder={handleReorder}
getTitle={config.title}
getSubtitle={config.subtitle}
/>
<SectionAddItemButton type={type}>{config.addLabel}</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,97 +0,0 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import type { LeftSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { CaretDownIcon } from "@phosphor-icons/react";
import { getDefaultSectionIconName } from "@reactive-resume/schema/resume/section-icons";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
import { SectionDropdownMenu } from "./section-menu";
type Props = React.ComponentProps<typeof AccordionContent> & {
type: LeftSidebarSection;
};
export function SectionBase({ type, className, ...props }: Props) {
const updateResumeData = useUpdateResumeData();
// Subscribe to only this section's slice, not the whole resume. Otherwise editing any field
// (which replaces the resume reference) re-renders all ~15 section wrappers on every keystroke.
// Immer keeps untouched slices reference-stable, so Zustand bails out of the unrelated sections.
const section = useCurrentBuilderResumeSelector((resume) => {
const data = resume.data;
return type === "basics"
? data.basics
: type === "summary"
? data.summary
: type === "picture"
? data.picture
: type === "custom"
? data.customSections
: data.sections[type];
});
const isHidden = "hidden" in section && section.hidden;
const hasSectionIcon = !["picture", "basics", "custom"].includes(type);
const rawIcon = "icon" in section && typeof section.icon === "string" ? section.icon : "";
const fallbackIcon = hasSectionIcon ? getDefaultSectionIconName(type as "summary" | SectionType) : "";
const sectionIcon = rawIcon === "none" ? "" : rawIcon || fallbackIcon;
const sectionTitle = ("title" in section && section.title) || getSectionTitle(type);
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
const onIconChange = (icon: string) => {
// Store "none" when user explicitly picks the empty/prohibit option
const valueToStore = icon === "" ? "none" : icon;
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.icon = valueToStore;
} else if (type !== "basics" && type !== "picture" && type !== "custom") {
draft.sections[type as SectionType].icon = valueToStore;
}
});
};
return (
<Accordion
id={`sidebar-${type}`}
value={collapsed ? [] : [type]}
onValueChange={() => toggleCollapsed(type)}
className={cn("space-y-4", isHidden && "opacity-50")}
>
<AccordionItem value={type} className="group/accordion-item space-y-4">
<div className="flex items-center">
<AccordionTrigger
className="me-2 items-center justify-center"
render={
<Button size="icon" variant="ghost" aria-label={t`Toggle ${sectionTitle} section`}>
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
</Button>
}
/>
<div className={cn("flex flex-1 items-center gap-x-4", hasSectionIcon && "gap-x-2")}>
{hasSectionIcon ? (
<IconPicker value={sectionIcon} onChange={onIconChange} size="icon" variant="ghost" />
) : (
getSectionIcon(type)
)}
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{sectionTitle}</h2>
</div>
{!["picture", "basics", "custom"].includes(type) && (
<SectionDropdownMenu type={type as "summary" | SectionType} />
)}
</div>
<AccordionContent className={cn("p-0", className)} {...props} />
</AccordionItem>
</Accordion>
);
}
@@ -1,348 +0,0 @@
import type {
CustomSectionItem,
CustomSectionType,
ResumeData,
SectionItem as SectionItemType,
SectionType,
} from "@reactive-resume/schema/resume/data";
import type { ButtonProps } from "@reactive-resume/ui/components/button";
import type { Ref } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowBendUpRightIcon,
CopySimpleIcon,
DotsSixVerticalIcon,
DotsThreeVerticalIcon,
EyeClosedIcon,
EyeIcon,
FileIcon,
FolderPlusIcon,
PencilSimpleLineIcon,
PlusCircleIcon,
PlusIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { Reorder, useDragControls } from "motion/react";
import { useMemo } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
// ============================================================================
// MoveItemSubmenu Component
// ============================================================================
type MoveItemSubmenuProps = {
type: CustomSectionType;
item: CustomSectionItem | SectionItemType;
customSectionId?: string;
};
/**
* Submenu component for moving items between sections/pages.
* Displays compatible targets grouped by page with options to:
* - Move to existing compatible section
* - Create new section on existing page
* - Create new page with new section
*/
function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
/** Compute compatible move targets grouped by page */
const moveTargets = useMemo(
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
[resume, type, customSectionId],
);
/** Get the current section's title (used when creating new sections) */
const currentSectionTitle = useMemo(
() => getSourceSectionTitle(resume.data, type, customSectionId),
[resume, type, customSectionId],
);
/** Handler: Move item to an existing section */
const handleMoveToSection = (targetSectionId: string) => {
updateResumeData((draft) => {
moveItem(draft, {
itemId: item.id,
type,
customSectionId,
target: { type: "section", sectionId: targetSectionId },
});
});
};
/** Handler: Create a new custom section on an existing page and move the item there */
const handleNewSectionOnPage = (pageIndex: number) => {
updateResumeData((draft) => {
moveItem(draft, {
itemId: item.id,
type,
customSectionId,
target: { type: "new-section", title: currentSectionTitle, pageIndex },
});
});
};
/** Handler: Create a new page with a new custom section and move the item there */
const handleNewPage = () => {
updateResumeData((draft) => {
moveItem(draft, {
itemId: item.id,
type,
customSectionId,
target: { type: "new-page", title: currentSectionTitle },
});
});
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowBendUpRightIcon />
<Trans>Move to</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{/* Render each page as a submenu */}
{moveTargets.map(({ pageIndex, sections }) => (
<DropdownMenuSub key={pageIndex}>
<DropdownMenuSubTrigger>
<FileIcon />
<Trans>Page {pageIndex + 1}</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{/* Existing compatible sections on this page */}
{sections.map(({ sectionId, sectionTitle }) => (
<DropdownMenuItem key={sectionId} onClick={() => handleMoveToSection(sectionId)}>
{sectionTitle}
</DropdownMenuItem>
))}
{/* Separator if there are existing sections */}
{sections.length > 0 && <DropdownMenuSeparator />}
{/* Option to create a new section on this page */}
<DropdownMenuItem onClick={() => handleNewSectionOnPage(pageIndex)}>
<FolderPlusIcon />
<Trans>New Section</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
<DropdownMenuSeparator />
{/* Option to create a new page with a new section */}
<DropdownMenuItem onClick={handleNewPage}>
<PlusCircleIcon />
<Trans>New Page</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
// ============================================================================
// SectionItem Component
// ============================================================================
type Props<T extends CustomSectionItem | SectionItemType> = {
type: CustomSectionType;
item: T;
title: string;
subtitle?: string;
customSectionId?: string;
// Injected by `AnimatePresence mode="popLayout"` to pop an exiting row out of the flow.
ref?: Ref<HTMLLIElement>;
};
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
type,
item,
title,
subtitle,
customSectionId,
ref,
}: Props<T>) {
const confirm = useConfirm();
const controls = useDragControls();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
// Returns the items array (and index) this row lives in, from either a custom or a built-in section.
const findItem = (draft: ResumeData) => {
const items = customSectionId
? draft.customSections.find((section) => section.id === customSectionId)?.items
: // Type assertion: when customSectionId is not provided, type is always a built-in SectionType
draft.sections[type as SectionType].items;
const index = items?.findIndex((_item) => _item.id === item.id) ?? -1;
return index === -1 || !items ? null : { items, index };
};
const onToggleVisibility = () => {
updateResumeData((draft) => {
const found = findItem(draft);
if (found) found.items[found.index].hidden = !found.items[found.index].hidden;
});
};
const onUpdate = () => {
// Type assertion needed because TypeScript can't narrow the union type through template literals
openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never);
};
const onDuplicate = () => {
// Type assertion needed because TypeScript can't narrow the union type through template literals
openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never);
};
const onDelete = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this item?`, {
confirmText: t({
comment: "Destructive confirmation button label when deleting a section item in resume builder",
message: "Delete",
}),
cancelText: t({
comment: "Confirmation dialog button label to abort deleting a section item in resume builder",
message: "Cancel",
}),
});
if (!confirmed) return;
updateResumeData((draft) => {
const found = findItem(draft);
if (found) found.items.splice(found.index, 1);
});
};
return (
<Reorder.Item
ref={ref}
id={atsFindingItemElementId(item.id)}
value={item}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: EASE_OUT_STRONG }}
whileDrag={{ boxShadow: "0 4px 12px rgb(0 0 0 / 0.08)" }}
className="group relative flex h-18 select-none border-b bg-background"
>
<div
className="flex cursor-grab touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 active:cursor-grabbing group-hover:opacity-100"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<DotsSixVerticalIcon />
</div>
<button
type="button"
onClick={onUpdate}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 ps-1.5 text-start opacity-100 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
item.hidden && "opacity-50",
)}
>
<div className="line-clamp-1 font-medium">{title}</div>
{subtitle && <div className="line-clamp-1 text-muted-foreground text-xs">{subtitle}</div>}
</button>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t`Options for ${title}`}
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
>
<DotsThreeVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onClick={onToggleVisibility}>
{item.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{item.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={onUpdate}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</Reorder.Item>
);
}
type AddButtonProps = Omit<ButtonProps, "type"> & {
type: CustomSectionType | "custom";
customSectionId?: string;
};
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
const { openDialog } = useDialogStore();
const handleAdd = () => {
if (type === "custom") {
openDialog("resume.sections.custom.create", undefined);
} else {
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
}
};
return (
<Button
variant="ghost"
onClick={handleAdd}
className={cn("h-12 w-full justify-start rounded-t-none", className)}
{...props}
>
<PlusIcon />
{children}
</Button>
);
}
@@ -1,263 +0,0 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { SectionDropdownMenu } from "./section-menu";
const mocks = vi.hoisted(() => ({
updateResumeData: vi.fn(),
toastAdd: vi.fn(),
resume: { value: {} as Resume },
}));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => mocks.resume.value,
useUpdateResumeData: () => mocks.updateResumeData,
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({
toast: { add: mocks.toastAdd },
}));
vi.mock("@/hooks/use-confirm", () => ({
useConfirm: () => vi.fn(),
}));
vi.mock("@/hooks/use-prompt", () => ({
usePrompt: () => vi.fn(),
}));
vi.mock("@/dialogs/store", () => ({
useDialogStore: () => ({
openDialog: vi.fn(),
}),
}));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
mocks.updateResumeData.mockClear();
mocks.toastAdd.mockClear();
mocks.resume.value = {
isLocked: false,
data: {
metadata: { page: { locale: "en-US" } },
sections: {
skills: {
title: "Skills",
columns: 2,
hidden: false,
layout: "default",
items: [],
},
experience: {
title: "Experience",
columns: 1,
hidden: false,
items: [
{ id: "unknown", company: "Mystery Co", period: "Recently" },
{ id: "older", company: "Older Co", period: "2018 - 2020" },
{ id: "current", company: "Current Co", period: "2023 - Present" },
],
},
education: {
title: "Education",
columns: 1,
hidden: false,
items: [
{ id: "earlier", school: "Earlier School", period: "2016 - 2020" },
{ id: "later", school: "Later School", period: "2020 - 2024" },
],
},
},
},
} as unknown as Resume;
});
function openSectionOptions() {
fireEvent.click(screen.getByRole("button", { name: "Section options" }));
return screen.findByRole("menu");
}
describe("SkillsSectionDropdownMenu", () => {
it("selects keyword bullets without changing item layout or columns", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="skills" />
</I18nProvider>,
);
screen.getByRole("button", { name: "Section options" }).click();
(await screen.findByRole("menuitem", { name: "Keyword layout" })).click();
(await screen.findByRole("menuitemradio", { name: "Bulleted list" })).click();
const draft = { sections: { skills: { layout: "default", columns: 2, keywordLayout: "inline", items: [] } } };
mocks.updateResumeData.mock.calls[0][0](draft);
expect(draft.sections.skills).toEqual({ layout: "default", columns: 2, keywordLayout: "list", items: [] });
});
it("updates resume data correctly when selecting 'inline' for skills section", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="skills" />
</I18nProvider>,
);
await openSectionOptions();
(await screen.findByRole("menuitem", { name: /columns/i })).click();
(await screen.findByRole("menuitemradio", { name: /inline/i })).click();
expect(mocks.updateResumeData).toHaveBeenCalledTimes(1);
expect(mocks.updateResumeData).toHaveBeenCalledWith(expect.any(Function));
const mutation = mocks.updateResumeData.mock.calls[0][0];
const mockDraft = {
sections: {
skills: {
title: "Skills",
columns: 2,
hidden: false,
layout: "default",
items: [],
},
},
};
mutation(mockDraft);
expect(mockDraft.sections.skills.layout).toBe("inline");
expect(mockDraft.sections.skills.columns).toBe(1);
});
it("updates resume data correctly when selecting numeric columns for skills section", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="skills" />
</I18nProvider>,
);
await openSectionOptions();
(await screen.findByRole("menuitem", { name: /columns/i })).click();
(await screen.findByRole("menuitemradio", { name: /3 columns/i })).click();
expect(mocks.updateResumeData).toHaveBeenCalledTimes(1);
expect(mocks.updateResumeData).toHaveBeenCalledWith(expect.any(Function));
const mutation = mocks.updateResumeData.mock.calls[0][0];
const mockDraft = {
sections: {
skills: {
title: "Skills",
columns: 2,
hidden: false,
layout: "default",
items: [],
},
},
};
mutation(mockDraft);
expect(mockDraft.sections.skills.layout).toBe("default");
expect(mockDraft.sections.skills.columns).toBe(3);
});
});
describe("heading visibility", () => {
it("toggles heading visibility without changing section visibility", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="experience" />
</I18nProvider>,
);
await openSectionOptions();
fireEvent.click(await screen.findByRole("menuitem", { name: "Hide heading" }));
expect(mocks.updateResumeData).toHaveBeenCalledTimes(1);
const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void;
const draft = structuredClone(mocks.resume.value.data);
mutation(draft);
expect(draft.sections.experience.showHeading).toBe(false);
expect(draft.sections.experience.hidden).toBe(false);
});
});
describe("chronological section sorting", () => {
it("sorts Experience once through one draft mutation and names only unresolved items", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="experience" />
</I18nProvider>,
);
await openSectionOptions();
fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by date" }));
expect(mocks.updateResumeData).toHaveBeenCalledTimes(1);
const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void;
const draft = structuredClone(mocks.resume.value.data);
mutation(draft);
expect(draft.sections.experience.items.map(({ id }) => id)).toEqual(["current", "older", "unknown"]);
expect(mocks.toastAdd).toHaveBeenCalledWith({
type: "warning",
description: "Could not sort these items; they stayed at the end: Mystery Co.",
});
});
it("sorts Education once without showing an unresolved notice", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="education" />
</I18nProvider>,
);
await openSectionOptions();
fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by date" }));
expect(mocks.updateResumeData).toHaveBeenCalledTimes(1);
const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void;
const draft = structuredClone(mocks.resume.value.data);
mutation(draft);
expect(draft.sections.education.items.map(({ id }) => id)).toEqual(["later", "earlier"]);
expect(mocks.toastAdd).not.toHaveBeenCalled();
});
it("disables sorting when the resume is locked", async () => {
mocks.resume.value = { ...mocks.resume.value, isLocked: true };
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="experience" />
</I18nProvider>,
);
await openSectionOptions();
const sortItem = await screen.findByRole("menuitem", { name: "Sort by date" });
expect(sortItem).toHaveAttribute("aria-disabled", "true");
fireEvent.keyDown(sortItem, { key: "Escape" });
expect(mocks.updateResumeData).not.toHaveBeenCalled();
});
it("does not offer date sorting for other built-in sections", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="skills" />
</I18nProvider>,
);
await openSectionOptions();
expect(screen.queryByRole("menuitem", { name: "Sort by date" })).not.toBeInTheDocument();
});
});
@@ -1,254 +0,0 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import {
BroomIcon,
ColumnsIcon,
EyeClosedIcon,
EyeIcon,
ListIcon,
PencilSimpleLineIcon,
PlusIcon,
SortDescendingIcon,
} from "@phosphor-icons/react";
import { sortSectionItemsByPeriod } from "@reactive-resume/resume/section-sort";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { usePrompt } from "@/hooks/use-prompt";
import { SkillKeywordLayoutMenu } from "./skill-keyword-layout-menu";
type Props = {
type: "summary" | SectionType;
};
export function SectionDropdownMenu({ type }: Props) {
const prompt = usePrompt();
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const resume = useCurrentResume();
const section = type === "summary" ? resume.data.summary : resume.data.sections[type];
const showHeading = section.showHeading !== false;
const dropDownValue =
type === "skills" && resume.data.sections[type].layout === "inline" ? "inline" : section.columns.toString();
const onAddItem = () => {
if (type === "summary") return;
openDialog(`resume.sections.${type}.create`, undefined);
};
const onSortByDate = () => {
if ((type !== "experience" && type !== "education") || resume.isLocked) return;
let unresolvedLabels: string[] = [];
if (type === "experience") {
const currentItems = resume.data.sections.experience.items;
const result = sortSectionItemsByPeriod(currentItems, resume.data.metadata.page.locale);
const labelById = new Map(currentItems.map((item) => [item.id, item.company.trim() || item.id]));
unresolvedLabels = result.unresolvedIds.map((id) => labelById.get(id) ?? id);
updateResumeData((draft) => {
draft.sections.experience.items = result.items;
});
} else {
const currentItems = resume.data.sections.education.items;
const result = sortSectionItemsByPeriod(currentItems, resume.data.metadata.page.locale);
const labelById = new Map(currentItems.map((item) => [item.id, item.school.trim() || item.id]));
unresolvedLabels = result.unresolvedIds.map((id) => labelById.get(id) ?? id);
updateResumeData((draft) => {
draft.sections.education.items = result.items;
});
}
if (unresolvedLabels.length > 0) {
toast.add({
type: "warning",
description: t`Could not sort these items; they stayed at the end: ${unresolvedLabels.join(", ")}.`,
});
}
};
const onToggleVisibility = () => {
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.hidden = !draft.summary.hidden;
} else {
draft.sections[type].hidden = !draft.sections[type].hidden;
}
});
};
const onToggleHeading = () => {
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.showHeading = !(draft.summary.showHeading !== false);
} else {
draft.sections[type].showHeading = !(draft.sections[type].showHeading !== false);
}
});
};
const onRenameSection = async () => {
const newTitle = await prompt(t`What do you want to rename this section to?`, {
description: t`Leave empty to reset the title to the original.`,
defaultValue: section.title,
});
if (newTitle === null || newTitle === section.title) return;
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.title = newTitle ?? "";
} else {
draft.sections[type].title = newTitle ?? "";
}
});
};
const onSetColumns = (value: string) => {
updateResumeData((draft) => {
if (type === "skills") {
if (value === "inline") {
draft.sections[type].layout = value;
draft.sections[type].columns = 1;
return;
}
draft.sections[type].layout = "default";
}
if (type === "summary") {
draft.summary.columns = Number.parseInt(value, 10);
} else {
draft.sections[type].columns = Number.parseInt(value, 10);
}
});
};
const onReset = async () => {
const confirmed = await confirm(t`Are you sure you want to reset this section?`, {
description: t`This will remove all items from this section.`,
confirmText: t({
comment: "Destructive confirmation button label when resetting a resume section",
message: "Reset",
}),
cancelText: t({
comment: "Confirmation dialog button label to abort resetting a resume section",
message: "Cancel",
}),
});
if (!confirmed) return;
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.content = "";
} else {
draft.sections[type].items = [];
}
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost" aria-label={t`Section options`}>
<ListIcon />
</Button>
}
/>
<DropdownMenuContent align="end">
{type !== "summary" && (
<>
<DropdownMenuGroup>
<DropdownMenuItem onClick={onAddItem}>
<PlusIcon />
<Trans>Add a new item</Trans>
</DropdownMenuItem>
{(type === "experience" || type === "education") && (
<DropdownMenuItem disabled={resume.isLocked} onClick={onSortByDate}>
<SortDescendingIcon />
<Trans>Sort by date</Trans>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem onClick={onToggleVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleHeading}>
{showHeading ? <EyeClosedIcon /> : <EyeIcon />}
{showHeading ? <Trans>Hide heading</Trans> : <Trans>Show heading</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onRenameSection}>
<PencilSimpleLineIcon />
<Trans>Rename</Trans>
</DropdownMenuItem>
{type === "skills" && <SkillKeywordLayoutMenu />}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={dropDownValue} onValueChange={onSetColumns}>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={column.toString()}>
<Plural value={column} one="# Column" other="# Columns" />
</DropdownMenuRadioItem>
))}
{type === "skills" && (
<>
<DropdownMenuSeparator />
<DropdownMenuRadioItem value="inline">
<Trans>1 Column / Inline</Trans>
</DropdownMenuRadioItem>
</>
)}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={onReset}>
<BroomIcon />
<Trans>Reset</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,75 +0,0 @@
// @vitest-environment happy-dom
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@reactive-resume/ui/components/dropdown-menu";
import { useResumeStore } from "@/features/resume/builder/draft";
import { SkillKeywordLayoutMenu } from "./skill-keyword-layout-menu";
const queryClient = { setQueryData: vi.fn() };
vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => queryClient }));
vi.mock("@tanstack/react-router", () => ({ useParams: () => ({ resumeId: "keywords" }) }));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { getById: { queryOptions: () => ({ queryKey: ["resume"] }) } } },
streamClient: {},
}));
beforeEach(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
const data = structuredClone(defaultResumeData);
data.customSections = [{ ...data.sections.skills, id: "custom", type: "skills", columns: 2 }];
useResumeStore.getState().initialize({
id: "keywords",
name: "Keywords",
slug: "keywords",
tags: [],
data,
isLocked: false,
updatedAt: new Date(),
});
});
afterEach(() => useResumeStore.getState().reset());
function renderMenu(custom: boolean) {
return render(
<I18nProvider i18n={i18n}>
<DropdownMenu>
<DropdownMenuTrigger>Options</DropdownMenuTrigger>
<DropdownMenuContent>
<SkillKeywordLayoutMenu sectionId={custom ? "custom" : undefined} />
</DropdownMenuContent>
</DropdownMenu>
</I18nProvider>,
);
}
it.each([false, true])("changes keyword presentation through real draft hook with custom=%s", async (custom) => {
renderMenu(custom);
const selected = () =>
custom
? useResumeStore.getState().resume?.data.customSections[0]
: useResumeStore.getState().resume?.data.sections.skills;
screen.getByRole("button", { name: "Options" }).click();
(await screen.findByRole("menuitem", { name: "Keyword layout" })).click();
(await screen.findByRole("menuitemradio", { name: "Bulleted list" })).click();
expect(selected()?.keywordLayout).toBe("list");
expect(selected()?.columns).toBe(custom ? 2 : 1);
act(() => useResumeStore.getState().undo());
expect(selected()?.keywordLayout).toBe("inline");
act(() => useResumeStore.getState().redo());
expect(selected()?.keywordLayout).toBe("list");
});
it.each([false, true])("disables keyword controls on locked resumes with custom=%s", async (custom) => {
useResumeStore.getState().patchResume((resume) => {
resume.isLocked = true;
});
renderMenu(custom);
screen.getByRole("button", { name: "Options" }).click();
const control = await screen.findByRole("menuitem", { name: "Keyword layout" });
expect(control.getAttribute("aria-disabled")).toBe("true");
control.click();
expect(screen.queryByRole("menuitemradio", { name: "Bulleted list" })).toBeNull();
});
@@ -1,51 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { ListIcon } from "@phosphor-icons/react";
import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
type SkillKeywordLayoutMenuProps = { sectionId?: string };
export function SkillKeywordLayoutMenu({ sectionId }: SkillKeywordLayoutMenuProps) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
const section = sectionId
? resume.data.customSections.find((section) => section.id === sectionId && section.type === "skills")
: resume.data.sections.skills;
if (!section) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={resume.isLocked}>
<ListIcon />
<Trans>Keyword layout</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={section.keywordLayout ?? "inline"}
onValueChange={(value) => {
if (value !== "inline" && value !== "list") return;
updateResumeData((draft) => {
const target = sectionId
? draft.customSections.find((section) => section.id === sectionId && section.type === "skills")
: draft.sections.skills;
if (target) target.keywordLayout = value;
});
}}
>
<DropdownMenuRadioItem value="inline">
<Trans>Inline</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">
<Trans>Bulleted list</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
@@ -66,6 +66,7 @@ vi.mock("../shared/section-base", () => ({
}));
const { AtsCheckSectionBuilder } = await import("./ats-check");
const { useEditorStore } = await import("@/features/resume/editor/store");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -150,7 +151,7 @@ describe("AtsCheckSectionBuilder", () => {
expect(screen.getByText("1 note")).toBeTruthy();
});
it("opens the owning sidebar section when a finding's location is clicked", () => {
it("opens what a finding is about in Write when its location is clicked", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "";
});
@@ -159,7 +160,7 @@ describe("AtsCheckSectionBuilder", () => {
fireEvent.click(screen.getByRole("button", { name: /Basics/ }));
expect(editorModeState.setMode).toHaveBeenCalledWith("write");
expect(sectionState.setCollapsed).toHaveBeenCalledWith("basics", false);
expect(useEditorStore.getState()).toMatchObject({ selection: { kind: "header" }, basicsOpen: true });
});
it("points typography findings at the right sidebar", () => {
@@ -19,14 +19,9 @@ import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
import { AtsPdfReportView } from "@/features/ats-checker/report/report-view";
import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check";
import { useResumeData } from "@/features/resume/builder/draft";
import { getScrollBehavior } from "@/features/resume/builder/section-recovery";
import { getScrollBehavior, revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import {
atsFindingItemElementId,
getAtsFindingLocation,
getAtsFindingMessage,
getAtsFindingTarget,
} from "@/libs/resume/ats";
import { getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "@/libs/resume/ats";
import { useEditorMode } from "../../../-components/use-editor-mode";
import { useSectionStore } from "../../../-store/section";
import { SectionBase } from "../shared/section-base";
@@ -115,14 +110,28 @@ function LiveLintTier() {
const target = getAtsFindingTarget(pointer, data);
if (!target) return;
// Content findings open Write, design findings open Design; then the field scrolls into view.
setMode(target.side === "left" ? "write" : "design");
setCollapsed(target.section, false);
// Content findings open the entry in Write; design findings open their section in Design.
if (target.side === "left") {
const customIndex = target.section === "custom" ? Number(pointer.split("/")[2]) : -1;
const sectionId = customIndex >= 0 ? data?.customSections[customIndex]?.id : target.section;
if (!sectionId) return;
setMode("write");
revealSelectionInPanel(
sectionId === "basics" || sectionId === "picture"
? { kind: "header" }
: target.itemId
? { kind: "item", sectionId, itemId: target.itemId }
: { kind: "section", sectionId },
);
return;
}
setMode("design");
setCollapsed(target.section, false);
requestAnimationFrame(() => {
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
document
.getElementById(`sidebar-${target.section}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
});
},
[data, setCollapsed, setMode],