mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 22:37:14 +10:00
feat: add section heading icons to PDF templates (#3127)
* feat: add section heading icons to PDF templates Add customizable Phosphor icons before section titles in PDF output. Users can toggle visibility globally via a new "Hide section heading icons" switch (independent of item-level icons) and customize individual section icons through the builder sidebar icon picker. - Add `icon` field to `baseSectionSchema` and `summarySchema` - Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat) - Implement `SectionHeadingIcon` component with heading font-size scaling - Support "none" sentinel for per-section icon hiding - Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data - Add icon picker to builder sidebar sections and custom section dialogs Closes #2632 * test: add unit tests for section heading icons - Add tests for getResumeSectionIcon() covering built-in sections, summary, custom sections, "none" sentinel, and default fallbacks - Add schema tests for baseSectionSchema icon field, summarySchema icon, and pageSchema hideSectionIcons default behavior * refactor: minor updates to icon display * Update apps/web/locales/es-ES.po Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -19,10 +19,13 @@ import {
|
||||
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;
|
||||
@@ -33,6 +36,7 @@ const defaultValues: FormValues = {
|
||||
id: "",
|
||||
title: "",
|
||||
type: "experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
@@ -59,6 +63,12 @@ function isCustomSectionType(value: string | null | undefined): value is CustomS
|
||||
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();
|
||||
@@ -68,6 +78,7 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
|
||||
id: generateId(),
|
||||
title: data?.title ?? "",
|
||||
type: (data?.type ?? "experience") as CustomSectionType,
|
||||
icon: data?.icon ?? "",
|
||||
columns: data?.columns ?? 1,
|
||||
hidden: data?.hidden ?? false,
|
||||
items: data?.items ?? [],
|
||||
@@ -126,7 +137,10 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: data,
|
||||
defaultValues: {
|
||||
...data,
|
||||
icon: data.icon ?? "",
|
||||
},
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
updateResumeData((draft) => {
|
||||
@@ -179,32 +193,57 @@ 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 (
|
||||
<>
|
||||
<form.Field name="title">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="sm:col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Title</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>
|
||||
<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) => (
|
||||
@@ -244,32 +283,57 @@ 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 (
|
||||
<>
|
||||
<form.Field name="title">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="sm:col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Title</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>
|
||||
<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) => (
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("getSourceSectionTitle", () => {
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "My Custom Title",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
@@ -54,6 +55,7 @@ describe("getCompatibleMoveTargets", () => {
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
@@ -120,6 +122,7 @@ describe("removeItemFromSource", () => {
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "i1" } as never],
|
||||
@@ -171,6 +174,7 @@ describe("addItemToSection", () => {
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
|
||||
@@ -228,6 +228,7 @@ export function createCustomSectionWithItem(
|
||||
id: newSectionId,
|
||||
type,
|
||||
title: sectionTitle,
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [item as never],
|
||||
@@ -265,6 +266,7 @@ export function createPageWithSection(
|
||||
id: newSectionId,
|
||||
type,
|
||||
title: sectionTitle,
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [item as never],
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("createSectionItem", () => {
|
||||
id: "custom-1",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
@@ -75,6 +76,7 @@ describe("updateSectionItem", () => {
|
||||
id: "custom-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "x", value: "old" } as never],
|
||||
|
||||
@@ -35,6 +35,8 @@ 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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
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 { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { SectionDropdownMenu } from "./section-menu";
|
||||
@@ -15,6 +17,7 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const data = resume.data;
|
||||
const section =
|
||||
type === "basics"
|
||||
@@ -28,9 +31,27 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
: 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 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}`}
|
||||
@@ -49,8 +70,12 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4">
|
||||
{getSectionIcon(type)}
|
||||
<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">
|
||||
{("title" in section && section.title) || getSectionTitle(type)}
|
||||
</h2>
|
||||
|
||||
@@ -261,7 +261,7 @@ function PageSectionForm() {
|
||||
<form.Field name="hideIcons">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full flex items-center gap-x-3 py-2"
|
||||
className="col-span-full flex items-center gap-x-3 py-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
@@ -276,7 +276,31 @@ function PageSectionForm() {
|
||||
}
|
||||
/>
|
||||
<FormLabel>
|
||||
<Trans>Hide all icons on the resume</Trans>
|
||||
<Trans>Hide Icons</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="hideSectionIcons">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full flex items-center gap-x-3 py-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Switch
|
||||
checked={field.state.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked);
|
||||
handleAutoSave("hideSectionIcons", checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormLabel>
|
||||
<Trans>Hide Section Icons</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user