From 286e165a6083a38ac65b65c86598e3529d789033 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Wed, 13 May 2026 01:11:19 +0200 Subject: [PATCH] [codex] Hide empty sections from page settings (#3052) * feat(web): hide empty layout sections * test: add "scizor" to templates metadata test cases --- .../src/dialogs/resume/template/data.test.ts | 1 + .../-sidebar/right/sections/layout/pages.tsx | 7 +- .../right/sections/layout/visibility.test.ts | 47 ++++++++++++ .../right/sections/layout/visibility.ts | 71 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.test.ts create mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.ts diff --git a/apps/web/src/dialogs/resume/template/data.test.ts b/apps/web/src/dialogs/resume/template/data.test.ts index 83fac65e5..22949444a 100644 --- a/apps/web/src/dialogs/resume/template/data.test.ts +++ b/apps/web/src/dialogs/resume/template/data.test.ts @@ -22,6 +22,7 @@ describe("templates metadata", () => { "onyx", "pikachu", "rhyhorn", + "scizor", ].sort(), ); }); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx index ea740784e..ac0a78467 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx @@ -23,6 +23,7 @@ import { cn } from "@reactive-resume/utils/style"; import { useCurrentResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft"; import { templates } from "@/dialogs/resume/template/data"; import { getSectionTitle } from "@/libs/resume/section"; +import { filterVisibleLayoutSectionIds } from "./visibility"; type ColumnId = "main" | "sidebar"; @@ -230,7 +231,11 @@ export function LayoutPages() { 1} sidebarPosition={templateSidebarPosition} onDelete={handleDeletePage} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.test.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.test.ts new file mode 100644 index 000000000..2512cdad8 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.test.ts @@ -0,0 +1,47 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { filterVisibleLayoutSectionIds } from "./visibility"; + +const createResumeData = (): ResumeData => structuredClone(defaultResumeData); + +describe("filterVisibleLayoutSectionIds", () => { + it("removes item-backed sections with no visible items", () => { + const data = createResumeData(); + data.sections.experience.items = [ + { hidden: false, company: "Reactive Resume" }, + { hidden: true, company: "Hidden Company" }, + ] as never; + data.sections.references.items = [{ hidden: true, name: "Hidden Reference" }] as never; + + expect(filterVisibleLayoutSectionIds(["experience", "volunteer", "references"], data)).toEqual(["experience"]); + }); + + it("keeps layout order for non-empty summary and custom sections", () => { + const data = createResumeData(); + data.summary.content = "

Available for staff roles.

"; + data.customSections = [ + { + id: "custom-visible", + type: "projects", + title: "Selected Work", + columns: 1, + hidden: false, + items: [{ hidden: false, name: "Resume Builder" }], + }, + { + id: "custom-empty", + type: "projects", + title: "Empty Work", + columns: 1, + hidden: false, + items: [], + }, + ] as never; + + expect(filterVisibleLayoutSectionIds(["custom-empty", "summary", "custom-visible"], data)).toEqual([ + "summary", + "custom-visible", + ]); + }); +}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.ts new file mode 100644 index 000000000..bd0abf771 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/visibility.ts @@ -0,0 +1,71 @@ +import type { CustomSectionType, ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; + +type HiddenItem = { + hidden: boolean; + [key: string]: unknown; +}; + +type ItemSectionLike = { + hidden: boolean; + items: HiddenItem[]; +}; + +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; + +type TitleBackedSectionType = keyof typeof primaryTitleFields; + +const hasText = (value: unknown): value is string => { + return typeof value === "string" && value.trim().length > 0; +}; + +const getPrimaryTitleField = (sectionType: CustomSectionType | SectionType | undefined): string | undefined => { + return primaryTitleFields[sectionType as TitleBackedSectionType]; +}; + +const hasValidPrimaryTitle = (item: HiddenItem, sectionType: CustomSectionType | SectionType | undefined): boolean => { + const titleField = getPrimaryTitleField(sectionType); + if (!titleField) return true; + return hasText(item[titleField]); +}; + +const hasVisibleItems = ( + section: ItemSectionLike, + sectionType: CustomSectionType | SectionType | undefined, +): boolean => { + return !section.hidden && section.items.some((item) => !item.hidden && hasValidPrimaryTitle(item, sectionType)); +}; + +const getBuiltInSection = (sectionId: string, data: ResumeData): ItemSectionLike | null => { + if (!(sectionId in data.sections)) return null; + + return data.sections[sectionId as SectionType] as ItemSectionLike; +}; + +const isVisibleLayoutSection = (sectionId: string, data: ResumeData): boolean => { + if (sectionId === "summary") return !data.summary.hidden && data.summary.content.trim().length > 0; + + const builtInSection = getBuiltInSection(sectionId, data); + if (builtInSection) return hasVisibleItems(builtInSection, sectionId as SectionType); + + const customSection = data.customSections.find((section) => section.id === sectionId); + if (customSection) return hasVisibleItems(customSection as ItemSectionLike, customSection.type); + + return false; +}; + +export const filterVisibleLayoutSectionIds = (sectionIds: string[], data: ResumeData): string[] => { + return sectionIds.filter((sectionId) => isVisibleLayoutSection(sectionId, data)); +};