diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index e673c5f..06474e1 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -88,6 +88,41 @@ describe("TailorMode", () => { ); }); + it("allows finalize when summary exists even if no project is selected", async () => { + render( + , + ); + + expect( + await screen.findByRole("button", { name: "Finalize & Move to Ready" }), + ).toBeEnabled(); + }); + + it("hides selected projects section when catalog is empty after load", async () => { + render( + , + ); + + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Selected Projects" }), + ).not.toBeInTheDocument(), + ); + }); + it("resets local state when job id changes", async () => { const { rerender } = render( = ({ catalog, + isCatalogLoading, summary, headline, jobDescription, @@ -239,20 +241,22 @@ export const TailoringSections: React.FC = ({ - - - Selected Projects - - - - - + {!isCatalogLoading && catalog.length > 0 && ( + + + Selected Projects + + + + + + )} diff --git a/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx b/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx index e7140e8..0faca10 100644 --- a/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx +++ b/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import * as api from "../../api"; import { useTracerReadiness } from "../../hooks/useTracerReadiness"; +import { canFinalizeTailoring } from "./rules"; import { TailoringSections } from "./TailoringSections"; import { useTailoringDraft } from "./useTailoringDraft"; @@ -42,6 +43,7 @@ export const TailoringWorkspace: React.FC = ( const { catalog, + isCatalogLoading, summary, setSummary, headline, @@ -235,7 +237,7 @@ export const TailoringWorkspace: React.FC = ( ? isSummarizing || isGeneratingPdf || isSaving : isGenerating || Boolean(tailorProps?.isFinalizing) || isSaving; - const canFinalize = summary.trim().length > 0 && selectedIds.size > 0; + const canFinalize = canFinalizeTailoring(summary); if (editorProps) { return ( @@ -280,6 +282,7 @@ export const TailoringWorkspace: React.FC = (
= ( = (
{!canFinalize && (

- Add a summary and select at least one project to{" "} + Add a summary to{" "} {finalizeVariant === "ready" ? "regenerate" : "finalize"}.

)} diff --git a/orchestrator/src/client/components/tailoring/rules.test.ts b/orchestrator/src/client/components/tailoring/rules.test.ts new file mode 100644 index 0000000..d790a2d --- /dev/null +++ b/orchestrator/src/client/components/tailoring/rules.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { canFinalizeTailoring } from "./rules"; + +describe("canFinalizeTailoring", () => { + it("returns true when summary has non-whitespace content", () => { + expect(canFinalizeTailoring("Summary")).toBe(true); + }); + + it("returns false when summary is empty", () => { + expect(canFinalizeTailoring(" ")).toBe(false); + }); +}); diff --git a/orchestrator/src/client/components/tailoring/rules.ts b/orchestrator/src/client/components/tailoring/rules.ts new file mode 100644 index 0000000..0b99860 --- /dev/null +++ b/orchestrator/src/client/components/tailoring/rules.ts @@ -0,0 +1,3 @@ +export function canFinalizeTailoring(summary: string): boolean { + return summary.trim().length > 0; +} diff --git a/orchestrator/src/client/components/tailoring/useTailoringDraft.ts b/orchestrator/src/client/components/tailoring/useTailoringDraft.ts index 7edc76a..8472df8 100644 --- a/orchestrator/src/client/components/tailoring/useTailoringDraft.ts +++ b/orchestrator/src/client/components/tailoring/useTailoringDraft.ts @@ -55,6 +55,7 @@ export function useTailoringDraft({ onDirtyChange, }: UseTailoringDraftParams) { const [catalog, setCatalog] = useState([]); + const [isCatalogLoading, setIsCatalogLoading] = useState(true); const [summary, setSummary] = useState(job.tailoredSummary || ""); const [headline, setHeadline] = useState(job.tailoredHeadline || ""); const [jobDescription, setJobDescription] = useState( @@ -148,10 +149,12 @@ export function useTailoringDraft({ }, [onDirtyChange]); useEffect(() => { + setIsCatalogLoading(true); api .getResumeProjectsCatalog() .then(setCatalog) - .catch(() => setCatalog([])); + .catch(() => setCatalog([])) + .finally(() => setIsCatalogLoading(false)); }, []); useEffect(() => { @@ -211,6 +214,7 @@ export function useTailoringDraft({ return { catalog, + isCatalogLoading, summary, setSummary, headline, diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 49ed259..cd439b0 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -21,6 +21,10 @@ import { TableRow, } from "@/components/ui/table"; import { clampInt } from "@/lib/utils"; +import { + toggleAiSelectable, + toggleMustInclude, +} from "../resume-projects-state"; import { BaseResumeSelection } from "./BaseResumeSelection"; type ReactiveResumeSectionProps = { @@ -202,48 +206,14 @@ export const ReactiveResumeSection: React.FC = ({ } onCheckedChange={(checked) => { if (!field.value) return; - const isChecked = checked === true; - const lockedIds = - field.value.lockedProjectIds.slice(); - const selectableIds = - field.value.aiSelectableProjectIds.slice(); - - if (isChecked) { - if (!lockedIds.includes(project.id)) - lockedIds.push(project.id); - const nextSelectable = - selectableIds.filter( - (id) => id !== project.id, - ); - const minCap = lockedIds.length; - field.onChange({ - ...field.value, - lockedProjectIds: lockedIds, - aiSelectableProjectIds: - nextSelectable, - maxProjects: Math.max( - field.value.maxProjects, - minCap, - ), - }); - return; - } - - const nextLocked = lockedIds.filter( - (id) => id !== project.id, - ); - if (!selectableIds.includes(project.id)) - selectableIds.push(project.id); - field.onChange({ - ...field.value, - lockedProjectIds: nextLocked, - aiSelectableProjectIds: selectableIds, - maxProjects: clampInt( - field.value.maxProjects, - nextLocked.length, + field.onChange( + toggleMustInclude({ + settings: field.value, + projectId: project.id, + checked: checked === true, maxProjectsTotal, - ), - }); + }), + ); }} /> @@ -259,21 +229,13 @@ export const ReactiveResumeSection: React.FC = ({ } onCheckedChange={(checked) => { if (!field.value) return; - const isChecked = checked === true; - const selectableIds = - field.value.aiSelectableProjectIds.slice(); - const nextSelectable = isChecked - ? selectableIds.includes(project.id) - ? selectableIds - : [...selectableIds, project.id] - : selectableIds.filter( - (id) => id !== project.id, - ); - field.onChange({ - ...field.value, - aiSelectableProjectIds: - nextSelectable, - }); + field.onChange( + toggleAiSelectable({ + settings: field.value, + projectId: project.id, + checked: checked === true, + }), + ); }} /> diff --git a/orchestrator/src/client/pages/settings/resume-projects-state.test.ts b/orchestrator/src/client/pages/settings/resume-projects-state.test.ts new file mode 100644 index 0000000..da32ca1 --- /dev/null +++ b/orchestrator/src/client/pages/settings/resume-projects-state.test.ts @@ -0,0 +1,57 @@ +import type { ResumeProjectsSettings } from "@shared/types.js"; +import { describe, expect, it } from "vitest"; +import { toggleAiSelectable, toggleMustInclude } from "./resume-projects-state"; + +const baseSettings: ResumeProjectsSettings = { + maxProjects: 2, + lockedProjectIds: [], + aiSelectableProjectIds: ["p1", "p2"], +}; + +describe("resume-projects-state", () => { + it("removes project from aiSelectable when must-include is enabled", () => { + const next = toggleMustInclude({ + settings: baseSettings, + projectId: "p1", + checked: true, + maxProjectsTotal: 3, + }); + + expect(next.lockedProjectIds).toEqual(["p1"]); + expect(next.aiSelectableProjectIds).toEqual(["p2"]); + }); + + it("does not auto-add project to aiSelectable when must-include is disabled", () => { + const start: ResumeProjectsSettings = { + maxProjects: 2, + lockedProjectIds: ["p1"], + aiSelectableProjectIds: [], + }; + + const next = toggleMustInclude({ + settings: start, + projectId: "p1", + checked: false, + maxProjectsTotal: 3, + }); + + expect(next.lockedProjectIds).toEqual([]); + expect(next.aiSelectableProjectIds).toEqual([]); + }); + + it("toggles aiSelectable explicitly", () => { + const add = toggleAiSelectable({ + settings: { ...baseSettings, aiSelectableProjectIds: ["p2"] }, + projectId: "p1", + checked: true, + }); + expect(add.aiSelectableProjectIds).toEqual(["p2", "p1"]); + + const remove = toggleAiSelectable({ + settings: add, + projectId: "p2", + checked: false, + }); + expect(remove.aiSelectableProjectIds).toEqual(["p1"]); + }); +}); diff --git a/orchestrator/src/client/pages/settings/resume-projects-state.ts b/orchestrator/src/client/pages/settings/resume-projects-state.ts new file mode 100644 index 0000000..a1f5d83 --- /dev/null +++ b/orchestrator/src/client/pages/settings/resume-projects-state.ts @@ -0,0 +1,55 @@ +import type { ResumeProjectsSettings } from "@shared/types.js"; +import { clampInt } from "@/lib/utils"; + +export function toggleMustInclude(args: { + settings: ResumeProjectsSettings; + projectId: string; + checked: boolean; + maxProjectsTotal: number; +}): ResumeProjectsSettings { + const { settings, projectId, checked, maxProjectsTotal } = args; + const lockedIds = settings.lockedProjectIds.slice(); + const selectableIds = settings.aiSelectableProjectIds.slice(); + + if (checked) { + if (!lockedIds.includes(projectId)) lockedIds.push(projectId); + const nextSelectable = selectableIds.filter((id) => id !== projectId); + const minCap = lockedIds.length; + return { + ...settings, + lockedProjectIds: lockedIds, + aiSelectableProjectIds: nextSelectable, + maxProjects: Math.max(settings.maxProjects, minCap), + }; + } + + const nextLocked = lockedIds.filter((id) => id !== projectId); + return { + ...settings, + lockedProjectIds: nextLocked, + maxProjects: clampInt( + settings.maxProjects, + nextLocked.length, + maxProjectsTotal, + ), + }; +} + +export function toggleAiSelectable(args: { + settings: ResumeProjectsSettings; + projectId: string; + checked: boolean; +}): ResumeProjectsSettings { + const { settings, projectId, checked } = args; + const selectableIds = settings.aiSelectableProjectIds.slice(); + const nextSelectable = checked + ? selectableIds.includes(projectId) + ? selectableIds + : [...selectableIds, projectId] + : selectableIds.filter((id) => id !== projectId); + + return { + ...settings, + aiSelectableProjectIds: nextSelectable, + }; +} diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts index c07ebdf..2ecf038 100644 --- a/orchestrator/src/server/services/pdf-tailoring.test.ts +++ b/orchestrator/src/server/services/pdf-tailoring.test.ts @@ -273,6 +273,18 @@ describe("PDF Service Tailoring Logic", () => { expect(projects.find((p: any) => p.id === "p2").visible).toBe(true); }); + it("keeps projects section visible when selected project list is explicitly empty", async () => { + await generatePdf("job-empty-projects", {}, "desc", "base.json", ""); + + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); + const projects = savedResumeJson.sections.projects.items; + + expect(projects.find((p: any) => p.id === "p1").visible).toBe(false); + expect(projects.find((p: any) => p.id === "p2").visible).toBe(false); + expect(savedResumeJson.sections.projects.visible).toBe(true); + }); + it("should fall back to AI selection if selectedProjectIds is null/undefined", async () => { // Setup AI selection mock for this test vi.mocked(projectSelection.pickProjectIdsForJob).mockResolvedValue(["p1"]); diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 426863e..bbb0823 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -221,7 +221,7 @@ export async function generatePdf( try { let selectedSet: Set; - if (selectedProjectIds) { + if (selectedProjectIds !== null && selectedProjectIds !== undefined) { selectedSet = new Set( selectedProjectIds .split(",") @@ -266,7 +266,7 @@ export async function generatePdf( if (!id) continue; typedItem.visible = selectedSet.has(id); } - projectsSection.visible = selectedSet.size > 0; + projectsSection.visible = true; } } catch (err) { console.warn(