mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
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:
@@ -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,
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
+2
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
-149
@@ -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>
|
||||
);
|
||||
}
|
||||
-75
@@ -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();
|
||||
});
|
||||
-51
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user