From 8df1b2555036beeb3a9f1a7b7212b077f9aaf91c Mon Sep 17 00:00:00 2001 From: Diego Vega Centeno Date: Sat, 5 Sep 2026 11:51:14 -0500 Subject: [PATCH] feat(skills): add inline layout option for skill items (#3358) * feat(skills): add inline layout option for skill items * fix: restore default skills layout (regressed by inline feature) - Restore metrics rowGap style for default layout - Only render LevelDisplay inside the row for inline layout, not default * refactor(pdf): Extract inline skills style logic from JSX to reusable function * test(pdf): add test coverage for inline skills item layout - Add test suite SkillsSectionInlineFormat to verify isInlineSkillsItem and getSkillsItemStyle behavior * test(pdf): add comprehensive test coverage for inline skills item style logic - Test combinations of proficiency, level, and keywords fields (0, 1, 3 fields) * test(schema): add test coverage for column equals 1 when layout is inline * test(web): add component-level tests for inline and columns layouts * test(import): add v4 parser-level test for missing skills layout * docs: regenerate skills layout references * test(docx): include skills layout in section fixtures --------- Co-authored-by: Amruth Pillai --- .../left/shared/section-menu.test.tsx | 120 ++++++++++++++++++ .../-sidebar/left/shared/section-menu.tsx | 22 +++- docs/guides/json-resume-schema.mdx | 13 +- packages/docx/src/section-renderers.test.ts | 1 + .../src/reactive-resume-v4-json.test.ts | 10 ++ .../import/src/reactive-resume-v4-json.tsx | 1 + .../pdf/src/templates/shared/sections.test.ts | 71 ++++++++++- .../pdf/src/templates/shared/sections.tsx | 32 ++++- packages/schema/src/resume/data.test.ts | 38 ++++++ packages/schema/src/resume/data.ts | 10 +- packages/schema/src/resume/default.ts | 2 +- packages/schema/src/resume/sample.ts | 1 + skills/resume-builder/references/schema.md | 1 + 13 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx new file mode 100644 index 000000000..e4e948456 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom + +import { 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 mockUpdateResumeData = vi.fn(); + +vi.mock("@/features/resume/builder/draft", () => ({ + useCurrentResume: () => ({ + data: { + sections: { + skills: { + title: "Skills", + columns: 2, + hidden: false, + layout: "default", + items: [], + }, + }, + }, + }), + useUpdateResumeData: () => mockUpdateResumeData, +})); + +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(() => { + mockUpdateResumeData.mockClear(); +}); + +describe("SkillsSectionDropdownMenu", () => { + it("updates resume data correctly when selecting 'inline' for skills section", async () => { + render( + + + , + ); + + screen.getByRole("button", { name: "Section options" }).click(); + + (await screen.findByRole("menuitem", { name: /columns/i })).click(); + + (await screen.findByRole("menuitemradio", { name: /inline/i })).click(); + + expect(mockUpdateResumeData).toHaveBeenCalledTimes(1); + expect(mockUpdateResumeData).toHaveBeenCalledWith(expect.any(Function)); + + const mutation = mockUpdateResumeData.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( + + + , + ); + + screen.getByRole("button", { name: "Section options" }).click(); + + (await screen.findByRole("menuitem", { name: /columns/i })).click(); + + (await screen.findByRole("menuitemradio", { name: /3 columns/i })).click(); + + expect(mockUpdateResumeData).toHaveBeenCalledTimes(1); + expect(mockUpdateResumeData).toHaveBeenCalledWith(expect.any(Function)); + + const mutation = mockUpdateResumeData.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); + }); +}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx index 09dd75260..3bae583e2 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx @@ -41,6 +41,8 @@ export function SectionDropdownMenu({ type }: Props) { const updateResumeData = useUpdateResumeData(); const resume = useCurrentResume(); const section = type === "summary" ? resume.data.summary : resume.data.sections[type]; + const dropDownValue = + type === "skills" && resume.data.sections[type].layout === "inline" ? "inline" : section.columns.toString(); const onAddItem = () => { if (type === "summary") return; @@ -76,6 +78,15 @@ export function SectionDropdownMenu({ type }: Props) { 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 { @@ -150,12 +161,21 @@ export function SectionDropdownMenu({ type }: Props) { - + {[1, 2, 3, 4, 5, 6].map((column) => ( ))} + + {type === "skills" && ( + <> + + + 1 Column / Inline + + + )} diff --git a/docs/guides/json-resume-schema.mdx b/docs/guides/json-resume-schema.mdx index eea138b28..a17e2c427 100644 --- a/docs/guides/json-resume-schema.mdx +++ b/docs/guides/json-resume-schema.mdx @@ -876,6 +876,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest "description": "The section to display the projects of the author." }, "skills": { + "description": "The section to display the skills of the author.", "type": "object", "properties": { "title": { @@ -967,6 +968,15 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest ] }, "description": "The items to display in the skills section." + }, + "layout": { + "default": "default", + "description": "The layout style for skill items. 'inline' places item fields next to name", + "type": "string", + "enum": [ + "default", + "inline" + ] } }, "required": [ @@ -977,8 +987,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest "keepTogether", "startOnNewPage", "items" - ], - "description": "The section to display the skills of the author." + ] }, "languages": { "type": "object", diff --git a/packages/docx/src/section-renderers.test.ts b/packages/docx/src/section-renderers.test.ts index fa31e62ce..053109ee0 100644 --- a/packages/docx/src/section-renderers.test.ts +++ b/packages/docx/src/section-renderers.test.ts @@ -84,6 +84,7 @@ const emptySection = (type: T): ResumeData["sections"][T] keepTogether: false, startOnNewPage: false, items: [], + ...(type === "skills" ? { layout: "default" as const } : {}), }) as ResumeData["sections"][T]; describe("renderBuiltInSection", () => { diff --git a/packages/import/src/reactive-resume-v4-json.test.ts b/packages/import/src/reactive-resume-v4-json.test.ts index 1d3466c5b..8c46bd448 100644 --- a/packages/import/src/reactive-resume-v4-json.test.ts +++ b/packages/import/src/reactive-resume-v4-json.test.ts @@ -424,3 +424,13 @@ describe("parseReactiveResumeV4JSON – rejects non-v4 input gracefully", () => expect(() => parseReactiveResumeV4JSON(arrayBranches)).toThrow(/v4/i); }); }); + +describe("parseReactiveResumeV4JSON – skills section", () => { + it("defaults skills layout to 'default' when 'layout' is missing from v4 data", () => { + // 'layout' already omitted + const v4 = makeV4Base({}); + + const result = parseReactiveResumeV4JSON(JSON.stringify(v4)); + expect(result.sections.skills.layout).toBe("default"); + }); +}); diff --git a/packages/import/src/reactive-resume-v4-json.tsx b/packages/import/src/reactive-resume-v4-json.tsx index 0f5cc01f0..fcb1b8cb0 100644 --- a/packages/import/src/reactive-resume-v4-json.tsx +++ b/packages/import/src/reactive-resume-v4-json.tsx @@ -385,6 +385,7 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { title: v4Data.sections.skills?.name ?? "", icon: "", columns: v4Data.sections.skills?.columns ?? 1, + layout: "default", hidden: !(v4Data.sections.skills?.visible ?? true), keepTogether: false, startOnNewPage: false, diff --git a/packages/pdf/src/templates/shared/sections.test.ts b/packages/pdf/src/templates/shared/sections.test.ts index 8df871ebf..776e479c4 100644 --- a/packages/pdf/src/templates/shared/sections.test.ts +++ b/packages/pdf/src/templates/shared/sections.test.ts @@ -1,7 +1,9 @@ +import type { SkillItem } from "@reactive-resume/schema/resume/data"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { getSectionHeadingTextStyle } from "./sections"; +import { getTemplateMetrics } from "./metrics"; +import { getSectionHeadingTextStyle, getSkillsItemStyle } from "./sections"; const source = readFileSync(fileURLToPath(new URL("./sections.tsx", import.meta.url)), "utf8"); @@ -54,3 +56,70 @@ describe("SectionShell", () => { expect(source).not.toContain('width: "auto"'); }); }); + +const mockMetrics = getTemplateMetrics({ gapX: 10, gapY: 10, marginX: 10, marginY: 10 }); + +const createSkillItem = (overrides: Partial = {}): SkillItem => ({ + id: "1", + name: "JavaScript", + level: 0, + keywords: [], + hidden: false, + proficiency: "", + icon: "", + iconColor: "", + ...overrides, +}); + +describe("SkillsSectionInlineFormat", () => { + it("uses isInlineSkillsItem in sections.tsx", () => { + expect(source).toContain("isInlineSkillsItem"); + }); + + it("returns default rowGap style when isInline is false", () => { + const mockItem = createSkillItem(); + expect(getSkillsItemStyle(false, mockItem, mockMetrics)).toEqual({ rowGap: 2.5 }); + }); + + it("does not apply alignItems: center when isInline is true and 0 secondary fields are present", () => { + // 0 secondary fields: no proficiency, no level, no keywords + const mockItem = createSkillItem(); + const style = getSkillsItemStyle(true, mockItem, mockMetrics); + expect(style).not.toEqual(expect.arrayContaining([{ alignItems: "center" }])); + }); + + it("applies alignItems: center when isInline is true and exactly 1 secondary field is present", () => { + // 1 secondary field: level only + const mockItem = createSkillItem({ level: 3 }); + const style = getSkillsItemStyle(true, mockItem, mockMetrics); + expect(style).toEqual(expect.arrayContaining([{ alignItems: "center" }])); + }); + + it("applies alignItems: center when isInline is true and proficiency is the only secondary field", () => { + // 1 secondary field: proficiency only + const mockItem = createSkillItem({ proficiency: "Advanced" }); + const style = getSkillsItemStyle(true, mockItem, mockMetrics); + expect(style).toEqual(expect.arrayContaining([{ alignItems: "center" }])); + }); + + it("applies alignItems: center when isInline is true and keywords is the only secondary field", () => { + // 1 secondary field: keywords only + const mockItem = createSkillItem({ keywords: ["React", "TypeScript"] }); + const style = getSkillsItemStyle(true, mockItem, mockMetrics); + expect(style).toEqual(expect.arrayContaining([{ alignItems: "center" }])); + }); + + it("does not apply alignItems: center when isInline is true and 2 secondary fields are present", () => { + // 2 secondary fields: level AND keywords + const mockItem = createSkillItem({ level: 3, keywords: ["React"] }); + const style = getSkillsItemStyle(true, mockItem, mockMetrics); + expect(style).not.toEqual(expect.arrayContaining([{ alignItems: "center" }])); + }); + + it("does not apply alignItems: center when isInline is true and 3 secondary fields are present", () => { + // 3 secondary fields: proficiency, level, AND keywords + const mockItem = createSkillItem({ proficiency: "Advanced", level: 3, keywords: ["React"] }); + const style = getSkillsItemStyle(true, mockItem, mockMetrics); + expect(style).not.toEqual(expect.arrayContaining([{ alignItems: "center" }])); + }); +}); diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index 7b191b443..7d02b7916 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -1164,6 +1164,28 @@ const ProjectsSection = ({ sectionId = "projects", sectionData }: ItemSectionPro ); }; +const inlineSkillsItemStyle = { + flexDirection: "row", + alignItems: "flex-start", + columnGap: 4, +} satisfies Style; + +export const getSkillsItemStyle = ( + isInline: boolean, + item: SkillItem, + metrics: ReturnType, +) => { + if (isInline) { + return composeStyles( + inlineSkillsItemStyle, + [hasSplitRowText(item.proficiency), Boolean(item.level), item.keywords.length > 0].filter(Boolean).length === 1 + ? { alignItems: "center" } + : undefined, + ); + } + return { rowGap: metrics.gapY(0.25) }; +}; + const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps = {}) => { const data = useRender(); const skills = sectionData ?? data.sections.skills; @@ -1173,15 +1195,17 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps {items.map((item) => ( - + - + {item.name} @@ -1190,9 +1214,9 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps 1 ? 1 : 0 }}> {hasSplitRowText(item.proficiency) && {item.proficiency}} {item.keywords.join(", ")} + {isInlineSkillsItem && } - - + {!isInlineSkillsItem && } ))} diff --git a/packages/schema/src/resume/data.test.ts b/packages/schema/src/resume/data.test.ts index 3f6000fa8..0a5956a25 100644 --- a/packages/schema/src/resume/data.test.ts +++ b/packages/schema/src/resume/data.test.ts @@ -14,6 +14,7 @@ import { resumeDataSchema, sectionTypeSchema, skillItemSchema, + skillsSectionSchema, styleRuleSchema, styleRulesSchema, summarySchema, @@ -684,3 +685,40 @@ describe("styleRulesSchema", () => { ).toBe(false); }); }); + +describe("skillsSectionSchema", () => { + it("forces columns to 1 when layout is 'inline'", () => { + const result = skillsSectionSchema.parse({ + title: "Skills", + columns: 2, + hidden: false, + layout: "inline", + items: [], + }); + expect(result.columns).toBe(1); + expect(result.layout).toBe("inline"); + }); + + it("preserves columns when layout is 'default'", () => { + const result = skillsSectionSchema.parse({ + title: "Skills", + columns: 3, + hidden: false, + layout: "default", + items: [], + }); + expect(result.columns).toBe(3); + expect(result.layout).toBe("default"); + }); + + it("defaults layout to 'default' when missing", () => { + const result = skillsSectionSchema.parse({ + title: "Skills", + columns: 2, + hidden: false, + items: [], + }); + expect(result.layout).toBe("default"); + expect(result.columns).toBe(2); + }); +}); diff --git a/packages/schema/src/resume/data.ts b/packages/schema/src/resume/data.ts index 70cb0f1b6..1264596e3 100644 --- a/packages/schema/src/resume/data.ts +++ b/packages/schema/src/resume/data.ts @@ -318,7 +318,15 @@ const publicationsSectionSchema = itemSection( "The items to display in the publications section.", ); const referencesSectionSchema = itemSection(referenceItemSchema, "The items to display in the references section."); -const skillsSectionSchema = itemSection(skillItemSchema, "The items to display in the skills section."); +export const skillsSectionSchema = itemSection(skillItemSchema, "The items to display in the skills section.") + .extend({ + layout: z + .enum(["default", "inline"]) + .default("default") + .catch("default") + .describe("The layout style for skill items. 'inline' places item fields next to name"), + }) + .transform((section) => (section.layout === "inline" ? { ...section, columns: 1 } : section)); const volunteerSectionSchema = itemSection(volunteerItemSchema, "The items to display in the volunteer section."); const sectionsSchema = z.object({ diff --git a/packages/schema/src/resume/default.ts b/packages/schema/src/resume/default.ts index 88d29b2b6..12798a2c7 100644 --- a/packages/schema/src/resume/default.ts +++ b/packages/schema/src/resume/default.ts @@ -47,7 +47,7 @@ export const defaultResumeData: ResumeData = { experience: section("briefcase"), education: section("graduation-cap"), projects: section("code-simple"), - skills: section("compass-tool"), + skills: { ...section("compass-tool"), layout: "default" }, languages: section("translate"), interests: section("football"), awards: section("trophy"), diff --git a/packages/schema/src/resume/sample.ts b/packages/schema/src/resume/sample.ts index dfc50667e..df1510e1c 100644 --- a/packages/schema/src/resume/sample.ts +++ b/packages/schema/src/resume/sample.ts @@ -191,6 +191,7 @@ export const sampleResumeData: ResumeData = { title: "", icon: "compass-tool", columns: 1, + layout: "default", hidden: false, keepTogether: false, startOnNewPage: false, diff --git a/skills/resume-builder/references/schema.md b/skills/resume-builder/references/schema.md index f33c2e2bc..641b256be 100644 --- a/skills/resume-builder/references/schema.md +++ b/skills/resume-builder/references/schema.md @@ -171,6 +171,7 @@ Choose one coherent shape for each union value. Required fields are local to tha | `sections.skills.items[].level` | `number` | yes | minimum: 0; maximum: 5; default: 0 | The proficiency level of the skill, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden. | | `sections.skills.items[].keywords` | `array` | yes | default: [] | The keywords associated with the skill, if any. These are displayed as tags below the name. | | `sections.skills.items[].keywords[]` | `string` | — | — | — | +| `sections.skills.layout` | `string` | no | enum: ["default","inline"]; default: "default" | The layout style for skill items. 'inline' places item fields next to name | | `sections.languages` | `object` | yes | — | The section to display the languages of the author. | | `sections.languages.title` | `string` | yes | — | The title of the section. | | `sections.languages.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |