mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
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:
co-authored by
Amruth Pillai
parent
ea2beb8450
commit
8df1b25550
@@ -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>
|
||||
|
||||
@@ -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" }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user