Make projects optional when moving jobs to Ready (#189)

* Make resume projects optional and reuse selection rules

* Apply Biome import/format fixes

* Handle explicit empty project selection in PDF generation

* Hide selected projects section when catalog is empty

* Avoid projects section flash while catalog is loading
This commit is contained in:
Shaheer Sarfaraz 2026-02-18 22:31:59 +00:00 committed by GitHub
parent 5ed74bb59c
commit b88d00b15d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 223 additions and 75 deletions

View File

@ -88,6 +88,41 @@ describe("TailorMode", () => {
);
});
it("allows finalize when summary exists even if no project is selected", async () => {
render(
<TailorMode
job={createJob({ selectedProjectIds: "" })}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
expect(
await screen.findByRole("button", { name: "Finalize & Move to Ready" }),
).toBeEnabled();
});
it("hides selected projects section when catalog is empty after load", async () => {
render(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
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(
<TailorMode

View File

@ -14,6 +14,7 @@ import type { EditableSkillGroup } from "../tailoring-utils";
interface TailoringSectionsProps {
catalog: ResumeProjectCatalogItem[];
isCatalogLoading: boolean;
summary: string;
headline: string;
jobDescription: string;
@ -48,6 +49,7 @@ const inputClass =
export const TailoringSections: React.FC<TailoringSectionsProps> = ({
catalog,
isCatalogLoading,
summary,
headline,
jobDescription,
@ -239,20 +241,22 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
</AccordionContent>
</AccordionItem>
<AccordionItem value="projects" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Selected Projects
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<ProjectSelector
catalog={catalog}
selectedIds={selectedIds}
onToggle={onToggleProject}
maxProjects={3}
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
{!isCatalogLoading && catalog.length > 0 && (
<AccordionItem value="projects" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Selected Projects
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<ProjectSelector
catalog={catalog}
selectedIds={selectedIds}
onToggle={onToggleProject}
maxProjects={3}
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
)}
<AccordionItem value="tracer-links" className={sectionClass}>
<AccordionTrigger className={triggerClass}>

View File

@ -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<TailoringWorkspaceProps> = (
const {
catalog,
isCatalogLoading,
summary,
setSummary,
headline,
@ -235,7 +237,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
? 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<TailoringWorkspaceProps> = (
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<TailoringSections
catalog={catalog}
isCatalogLoading={isCatalogLoading}
summary={summary}
headline={headline}
jobDescription={jobDescription}
@ -371,6 +374,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
<TailoringSections
catalog={catalog}
isCatalogLoading={isCatalogLoading}
summary={summary}
headline={headline}
jobDescription={jobDescription}
@ -399,7 +403,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
<div className="space-y-2">
{!canFinalize && (
<p className="text-center text-[10px] text-muted-foreground">
Add a summary and select at least one project to{" "}
Add a summary to{" "}
{finalizeVariant === "ready" ? "regenerate" : "finalize"}.
</p>
)}

View File

@ -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);
});
});

View File

@ -0,0 +1,3 @@
export function canFinalizeTailoring(summary: string): boolean {
return summary.trim().length > 0;
}

View File

@ -55,6 +55,7 @@ export function useTailoringDraft({
onDirtyChange,
}: UseTailoringDraftParams) {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
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,

View File

@ -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<ReactiveResumeSectionProps> = ({
}
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,
),
});
}),
);
}}
/>
</TableCell>
@ -259,21 +229,13 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
}
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,
}),
);
}}
/>
</TableCell>

View File

@ -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"]);
});
});

View File

@ -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,
};
}

View File

@ -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"]);

View File

@ -221,7 +221,7 @@ export async function generatePdf(
try {
let selectedSet: Set<string>;
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(