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:
parent
5ed74bb59c
commit
b88d00b15d
@ -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
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
12
orchestrator/src/client/components/tailoring/rules.test.ts
Normal file
12
orchestrator/src/client/components/tailoring/rules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
3
orchestrator/src/client/components/tailoring/rules.ts
Normal file
3
orchestrator/src/client/components/tailoring/rules.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function canFinalizeTailoring(summary: string): boolean {
|
||||
return summary.trim().length > 0;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"]);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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"]);
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user