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 <im.amruth@gmail.com>
This commit is contained in:
Diego Vega Centeno
2026-09-05 09:51:14 -07:00
committed by GitHub
co-authored by Amruth Pillai
parent ea2beb8450
commit 8df1b25550
13 changed files with 312 additions and 10 deletions
@@ -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(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="skills" />
</I18nProvider>,
);
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(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="skills" />
</I18nProvider>,
);
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);
});
});
@@ -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) {
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
<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>
+11 -2
View File
@@ -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",
@@ -84,6 +84,7 @@ const emptySection = <T extends SectionType>(type: T): ResumeData["sections"][T]
keepTogether: false,
startOnNewPage: false,
items: [],
...(type === "skills" ? { layout: "default" as const } : {}),
}) as ResumeData["sections"][T];
describe("renderBuiltInSection", () => {
@@ -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");
});
});
@@ -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,
@@ -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> = {}): 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" }]));
});
});
+28 -4
View File
@@ -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<typeof getTemplateMetrics>,
) => {
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<SkillItem> = {}) => {
const data = useRender();
const skills = sectionData ?? data.sections.skills;
@@ -1173,15 +1195,17 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps<S
if (items.length === 0) return null;
const isInlineSkillsItem = "layout" in skills && skills.layout === "inline";
return (
<SectionShell sectionId={sectionId} title={skills.title}>
<SectionItems columns={skills.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id} style={{ rowGap: metrics.gapY(0.25) }}>
<SectionItem key={item.id} itemId={item.id} style={getSkillsItemStyle(isInlineSkillsItem, item, metrics)}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold semanticField="name" style={{ flex: 1 }}>
<Bold semanticField="name" style={composeStyles(isInlineSkillsItem ? undefined : { flex: 1 })}>
{item.name}
</Bold>
</View>
@@ -1190,9 +1214,9 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps<S
<View style={{ flexGrow: skills.columns > 1 ? 1 : 0 }}>
{hasSplitRowText(item.proficiency) && <Text semanticField="proficiency">{item.proficiency}</Text>}
<Small semanticField="keywords">{item.keywords.join(", ")}</Small>
{isInlineSkillsItem && <LevelDisplay level={item.level} />}
</View>
<LevelDisplay level={item.level} />
{!isInlineSkillsItem && <LevelDisplay level={item.level} />}
</SectionItem>
))}
</SectionItems>
+38
View File
@@ -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);
});
});
+9 -1
View File
@@ -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({
+1 -1
View File
@@ -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"),
+1
View File
@@ -191,6 +191,7 @@ export const sampleResumeData: ResumeData = {
title: "",
icon: "compass-tool",
columns: 1,
layout: "default",
hidden: false,
keepTogether: false,
startOnNewPage: false,
@@ -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. |