From 0713cf20d4eeda112bddbc3a6c545ed351f94c47 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 11 May 2026 09:27:14 +0200 Subject: [PATCH] fix: filter invalid/empty section items and experience roles --- apps/web/src/components/input/rich-input.tsx | 2 - .../src/templates/shared/filtering.test.ts | 60 ++++++++++++- .../pdf/src/templates/shared/filtering.ts | 86 +++++++++++++++++-- .../pdf/src/templates/shared/sections.tsx | 30 +++---- 4 files changed, 152 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index 3bcbe36e2..38090f332 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -104,8 +104,6 @@ export function RichInput({ value, onChange, style, className, editorClassName, const textDirection = isRTL(i18n.locale) ? "rtl" : undefined; const [isFullscreen, setIsFullscreen] = useState(false); - console.log(value); - const editor = useEditor({ ...options, extensions, diff --git a/packages/pdf/src/templates/shared/filtering.test.ts b/packages/pdf/src/templates/shared/filtering.test.ts index 1c51a903d..48efce041 100644 --- a/packages/pdf/src/templates/shared/filtering.test.ts +++ b/packages/pdf/src/templates/shared/filtering.test.ts @@ -27,6 +27,38 @@ describe("filterItems", () => { const items = [{ hidden: false, name: "Alice", level: 4 }]; expect(filterItems(items)).toEqual([{ hidden: false, name: "Alice", level: 4 }]); }); + + it("filters items with invalid primary titles when a section type is provided", () => { + const items = [ + { hidden: false, company: " ", position: "Engineer" }, + { hidden: false, company: "Acme", position: "Engineer" }, + { hidden: false, company: "\n\t", position: "Manager" }, + ]; + + expect(filterItems(items, "experience")).toEqual([{ hidden: false, company: "Acme", position: "Engineer" }]); + }); + + it("filters invalid experience roles by position", () => { + const items = [ + { + hidden: false, + company: "Acme", + roles: [ + { id: "role-1", position: " ", period: "2020", description: "" }, + { id: "role-2", position: "Lead Engineer", period: "2021", description: "" }, + { id: "role-3", position: "\n", period: "2022", description: "" }, + ], + }, + ]; + + expect(filterItems(items, "experience")).toEqual([ + { + hidden: false, + company: "Acme", + roles: [{ id: "role-2", position: "Lead Engineer", period: "2021", description: "" }], + }, + ]); + }); }); describe("hasVisibleItems", () => { @@ -45,6 +77,10 @@ describe("hasVisibleItems", () => { it("returns false for empty items", () => { expect(hasVisibleItems({ hidden: false, items: [] })).toBe(false); }); + + it("returns false when all items have invalid primary titles", () => { + expect(hasVisibleItems({ hidden: false, items: [{ hidden: false, school: " " }] }, "education")).toBe(false); + }); }); describe("isVisibleSummary", () => { @@ -69,7 +105,7 @@ describe("isSectionVisible", () => { const data = { summary: { hidden: false, content: "

Hi

" }, sections: { - experience: { hidden: false, items: [{ hidden: false }] }, + experience: { hidden: false, items: [{ hidden: false, company: "Acme" }] }, skills: { hidden: false, items: [] }, education: { hidden: true, items: [{ hidden: false }] }, }, @@ -88,6 +124,15 @@ describe("isSectionVisible", () => { expect(isSectionVisible("experience", data)).toBe(true); }); + it("returns false for built-in section when all items have invalid primary titles", () => { + expect( + isSectionVisible("experience", { + ...data, + sections: { experience: { hidden: false, items: [{ hidden: false, company: " " }] } }, + }), + ).toBe(false); + }); + it("returns false for built-in section with no items", () => { expect(isSectionVisible("skills", data)).toBe(false); }); @@ -100,6 +145,17 @@ describe("isSectionVisible", () => { expect(isSectionVisible("ext-1", data)).toBe(true); }); + it("uses custom section type to validate item primary titles", () => { + expect( + isSectionVisible("ext-education", { + ...data, + customSections: [ + { id: "ext-education", type: "education", hidden: false, items: [{ hidden: false, school: "" }] }, + ], + }), + ).toBe(false); + }); + it("returns false for unknown section id", () => { expect(isSectionVisible("does-not-exist", data)).toBe(false); }); @@ -109,7 +165,7 @@ describe("filterSections", () => { const data = { summary: { hidden: false, content: "

Hi

" }, sections: { - experience: { hidden: false, items: [{ hidden: false }] }, + experience: { hidden: false, items: [{ hidden: false, company: "Acme" }] }, skills: { hidden: false, items: [] }, }, customSections: [], diff --git a/packages/pdf/src/templates/shared/filtering.ts b/packages/pdf/src/templates/shared/filtering.ts index 27101ee8b..593bd6bae 100644 --- a/packages/pdf/src/templates/shared/filtering.ts +++ b/packages/pdf/src/templates/shared/filtering.ts @@ -1,20 +1,43 @@ -import type { Summary } from "@reactive-resume/schema/resume/data"; +import type { CustomSectionType, Summary } from "@reactive-resume/schema/resume/data"; type HiddenItem = { hidden: boolean; + [key: string]: unknown; }; +type TitleBackedSectionType = Exclude; + type ItemSectionLike = { hidden: boolean; items: T[]; }; +type CustomSectionLike = ItemSectionLike & { + id: string; + type?: string; +}; + type FilterableData = { summary: Pick; sections: Partial>; - customSections: Array; + customSections: CustomSectionLike[]; }; +const primaryTitleFields = { + profiles: "network", + experience: "company", + education: "school", + projects: "name", + skills: "name", + languages: "language", + interests: "name", + awards: "title", + certifications: "title", + publications: "title", + volunteer: "organization", + references: "name", +} satisfies Record; + const isItemSection = (section: unknown): section is ItemSectionLike => { return typeof section === "object" && section !== null && "items" in section; }; @@ -23,12 +46,48 @@ const isSummarySection = (section: unknown): section is Summary => { return typeof section === "object" && section !== null && "content" in section; }; -export const filterItems = (items: T[]): T[] => { - return items.filter((item) => !item.hidden); +const hasText = (value: unknown): value is string => { + return typeof value === "string" && value.trim().length > 0; }; -export const hasVisibleItems = (section: ItemSectionLike): boolean => { - return !section.hidden && filterItems(section.items).length > 0; +const getPrimaryTitleField = (sectionType?: string): string | undefined => { + return primaryTitleFields[sectionType as TitleBackedSectionType]; +}; + +const hasValidPrimaryTitle = (item: HiddenItem, sectionType?: string): boolean => { + const titleField = getPrimaryTitleField(sectionType); + + if (!titleField) return true; + + return hasText((item as Record)[titleField]); +}; + +const hasVisibleExperienceRole = (role: unknown): boolean => { + return typeof role === "object" && role !== null && hasText((role as { position?: unknown }).position); +}; + +const filterExperienceRoles = (item: T, sectionType?: string): T => { + if (sectionType !== "experience") return item; + + const roles = (item as { roles?: unknown }).roles; + + if (!Array.isArray(roles)) return item; + + const visibleRoles = roles.filter(hasVisibleExperienceRole); + + if (visibleRoles.length === roles.length) return item; + + return { ...item, roles: visibleRoles } as T; +}; + +export const filterItems = (items: T[], sectionType?: string): T[] => { + return items + .filter((item) => !item.hidden && hasValidPrimaryTitle(item, sectionType)) + .map((item) => filterExperienceRoles(item, sectionType)); +}; + +export const hasVisibleItems = (section: ItemSectionLike, sectionType?: string): boolean => { + return !section.hidden && filterItems(section.items, sectionType).length > 0; }; export const isVisibleSummary = (summary: Pick): boolean => { @@ -41,12 +100,25 @@ const getSectionForFiltering = (sectionId: string, data: FilterableData) => { return data.sections[sectionId] ?? data.customSections.find((section) => section.id === sectionId); }; +const getSectionTypeForFiltering = (sectionId: string, section: unknown): string | undefined => { + if (sectionId === "summary") return "summary"; + + if (typeof section === "object" && section !== null && "type" in section) { + const type = (section as { type?: unknown }).type; + + if (typeof type === "string") return type; + } + + return sectionId; +}; + export const isSectionVisible = (sectionId: string, data: FilterableData): boolean => { const section = getSectionForFiltering(sectionId, data); + const sectionType = getSectionTypeForFiltering(sectionId, section); if (!section) return false; if (isSummarySection(section)) return isVisibleSummary(section); - if (isItemSection(section)) return hasVisibleItems(section); + if (isItemSection(section)) return hasVisibleItems(section, sectionType); return false; }; diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index 9c66eedff..2a6432a9f 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -51,10 +51,10 @@ const SectionItemsContext = createContext({ itemStyle: const useSectionItemsContext = () => useContext(SectionItemsContext); -const getVisibleItems = (section: ItemSection): T[] => { - if (!hasVisibleItems(section)) return []; +const getVisibleItems = (section: ItemSection, sectionType?: string): T[] => { + if (!hasVisibleItems(section, sectionType)) return []; - return filterItems(section.items); + return filterItems(section.items, sectionType); }; const SectionShell = ({ @@ -285,7 +285,7 @@ const ProfileSection = ({ } = {}) => { const data = useRender(); const profiles = sectionData ?? data.sections.profiles; - const items = getVisibleItems(profiles); + const items = getVisibleItems(profiles, "profiles"); const inlineStyle = useTemplateStyle("inline"); if (items.length === 0) return null; @@ -318,7 +318,7 @@ const ExperienceSection = ({ } = {}) => { const data = useRender(); const experience = sectionData ?? data.sections.experience; - const items = getVisibleItems(experience); + const items = getVisibleItems(experience, "experience"); const splitRowStyle = useSectionSplitRowStyle(); const alignRightStyle = useTemplateStyle("alignRight"); const inlineItemHeader = useTemplateFeature("inlineItemHeader"); @@ -400,7 +400,7 @@ const EducationSection = ({ } = {}) => { const data = useRender(); const education = sectionData ?? data.sections.education; - const items = getVisibleItems(education); + const items = getVisibleItems(education, "education"); const splitRowStyle = useSectionSplitRowStyle(); const alignRightStyle = useTemplateStyle("alignRight"); const inlineItemHeader = useTemplateFeature("inlineItemHeader"); @@ -474,7 +474,7 @@ const ProjectsSection = ({ } = {}) => { const data = useRender(); const projects = sectionData ?? data.sections.projects; - const items = getVisibleItems(projects); + const items = getVisibleItems(projects, "projects"); const splitRowStyle = useSectionSplitRowStyle(); const alignRightStyle = useTemplateStyle("alignRight"); @@ -511,7 +511,7 @@ const SkillsSection = ({ } = {}) => { const data = useRender(); const skills = sectionData ?? data.sections.skills; - const items = getVisibleItems(skills); + const items = getVisibleItems(skills, "skills"); const inlineStyle = useTemplateStyle("inline"); const metrics = getTemplateMetrics(data.metadata.page); @@ -551,7 +551,7 @@ const LanguagesSection = ({ } = {}) => { const data = useRender(); const languages = sectionData ?? data.sections.languages; - const items = getVisibleItems(languages); + const items = getVisibleItems(languages, "languages"); if (items.length === 0) return null; @@ -581,7 +581,7 @@ const InterestsSection = ({ } = {}) => { const data = useRender(); const interests = sectionData ?? data.sections.interests; - const items = getVisibleItems(interests); + const items = getVisibleItems(interests, "interests"); const inlineStyle = useTemplateStyle("inline"); if (items.length === 0) return null; @@ -615,7 +615,7 @@ const AwardsSection = ({ } = {}) => { const data = useRender(); const awards = sectionData ?? data.sections.awards; - const items = getVisibleItems(awards); + const items = getVisibleItems(awards, "awards"); const splitRowStyle = useTemplateStyle("splitRow"); const alignRightStyle = useTemplateStyle("alignRight"); @@ -652,7 +652,7 @@ const CertificationsSection = ({ } = {}) => { const data = useRender(); const certifications = sectionData ?? data.sections.certifications; - const items = getVisibleItems(certifications); + const items = getVisibleItems(certifications, "certifications"); if (items.length === 0) return null; @@ -685,7 +685,7 @@ const PublicationsSection = ({ } = {}) => { const data = useRender(); const publications = sectionData ?? data.sections.publications; - const items = getVisibleItems(publications); + const items = getVisibleItems(publications, "publications"); if (items.length === 0) return null; @@ -718,7 +718,7 @@ const VolunteerSection = ({ } = {}) => { const data = useRender(); const volunteer = sectionData ?? data.sections.volunteer; - const items = getVisibleItems(volunteer); + const items = getVisibleItems(volunteer, "volunteer"); const splitRowStyle = useSectionSplitRowStyle(); const alignRightStyle = useTemplateStyle("alignRight"); const inlineItemHeader = useTemplateFeature("inlineItemHeader"); @@ -767,7 +767,7 @@ const ReferencesSection = ({ } = {}) => { const data = useRender(); const references = sectionData ?? data.sections.references; - const items = getVisibleItems(references); + const items = getVisibleItems(references, "references"); if (items.length === 0) return null;