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:
JamesGoslings
2026-06-01 20:02:42 +08:00
committed by GitHub
parent 1522794733
commit b932711f08
81 changed files with 1153 additions and 306 deletions
@@ -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"');
});
});
+111 -47
View File
@@ -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) => (