Inputs getting unset fix (#91)
* codex 5.3 one shot * prevent cursor changing position
This commit is contained in:
parent
6353a23f6f
commit
0e6f0bcf88
@ -52,6 +52,7 @@ interface ReadyPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
onTailoringDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
const safeFilenamePart = (value: string | null | undefined) =>
|
||||
@ -61,6 +62,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
onTailoringDirtyChange,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("ready");
|
||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||
@ -84,7 +86,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
// Reset mode when job changes
|
||||
useEffect(() => {
|
||||
setMode("ready");
|
||||
}, []);
|
||||
onTailoringDirtyChange?.(false);
|
||||
}, [job?.id, onTailoringDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "tailor") {
|
||||
onTailoringDirtyChange?.(false);
|
||||
}
|
||||
}, [mode, onTailoringDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onTailoringDirtyChange?.(false);
|
||||
}, [onTailoringDirtyChange]);
|
||||
|
||||
// Compute derived values
|
||||
const pdfHref = job
|
||||
@ -252,6 +265,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
onFinalize={handleTailorFinalize}
|
||||
isFinalizing={isRegenerating}
|
||||
variant="ready"
|
||||
onDirtyChange={onTailoringDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
126
orchestrator/src/client/components/TailoringEditor.test.tsx
Normal file
126
orchestrator/src/client/components/TailoringEditor.test.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { TailoringEditor } from "./TailoringEditor";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||
updateJob: vi.fn().mockResolvedValue({}),
|
||||
summarizeJob: vi.fn(),
|
||||
generateJobPdf: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
id: "job-1",
|
||||
tailoredSummary: "Saved summary",
|
||||
jobDescription: "Saved description",
|
||||
selectedProjectIds: "p1",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
|
||||
describe("TailoringEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||
const { rerender } = render(
|
||||
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TailoringEditor
|
||||
job={createJob({ tailoredSummary: "Older server value" })}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft");
|
||||
});
|
||||
|
||||
it("resets local state when job id changes", async () => {
|
||||
const { rerender } = render(
|
||||
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TailoringEditor
|
||||
job={createJob({
|
||||
id: "job-2",
|
||||
tailoredSummary: "New job summary",
|
||||
jobDescription: "New job description",
|
||||
selectedProjectIds: "",
|
||||
})}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary");
|
||||
});
|
||||
|
||||
it("emits dirty state changes", async () => {
|
||||
const onDirtyChange = vi.fn();
|
||||
render(
|
||||
<TailoringEditor
|
||||
job={createJob()}
|
||||
onUpdate={vi.fn()}
|
||||
onDirtyChange={onDirtyChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
});
|
||||
|
||||
expect(onDirtyChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not sync same-job props while summary field is focused", async () => {
|
||||
const { rerender } = render(
|
||||
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
const summary = screen.getByLabelText("Tailored Summary");
|
||||
fireEvent.focus(summary);
|
||||
|
||||
rerender(
|
||||
<TailoringEditor
|
||||
job={createJob({ tailoredSummary: "Incoming from poll" })}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Saved summary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@ -22,6 +22,17 @@ interface TailoringEditorProps {
|
||||
onBeforeGenerate?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
const parseSelectedIds = (value: string | null | undefined) =>
|
||||
new Set(value?.split(",").filter(Boolean) ?? []);
|
||||
|
||||
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
|
||||
if (current.size !== saved.size) return true;
|
||||
for (const id of current) {
|
||||
if (!saved.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
job,
|
||||
onUpdate,
|
||||
@ -31,52 +42,88 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||
const [savedDescription, setSavedDescription] = useState(job.jobDescription || "");
|
||||
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeField, setActiveField] = useState<"summary" | "description" | null>(
|
||||
null,
|
||||
);
|
||||
const lastJobIdRef = useRef(job.id);
|
||||
|
||||
const savedSelectedIds = useMemo(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
}, [job.selectedProjectIds]);
|
||||
|
||||
const hasSelectionDiff = useMemo(() => {
|
||||
if (selectedIds.size !== savedSelectedIds.size) return true;
|
||||
for (const id of selectedIds) {
|
||||
if (!savedSelectedIds.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [selectedIds, savedSelectedIds]);
|
||||
|
||||
const isDirty =
|
||||
summary !== (job.tailoredSummary || "") ||
|
||||
jobDescription !== (job.jobDescription || "") ||
|
||||
hasSelectionDiff;
|
||||
const isDirty = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load project catalog
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
|
||||
// Set initial selection
|
||||
if (job.selectedProjectIds) {
|
||||
setSelectedIds(
|
||||
new Set(job.selectedProjectIds.split(",").filter(Boolean)),
|
||||
);
|
||||
}
|
||||
setJobDescription(job.jobDescription || "");
|
||||
}, [job.selectedProjectIds, job.jobDescription]);
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setSummary(job.tailoredSummary || "");
|
||||
}, [job.tailoredSummary]);
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingSummary = job.tailoredSummary || "";
|
||||
const incomingDescription = job.jobDescription || "";
|
||||
const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds);
|
||||
|
||||
if (job.id !== lastJobIdRef.current) {
|
||||
lastJobIdRef.current = job.id;
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirty || activeField !== null) return;
|
||||
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
}, [
|
||||
job.id,
|
||||
job.tailoredSummary,
|
||||
job.jobDescription,
|
||||
job.selectedProjectIds,
|
||||
isDirty,
|
||||
activeField,
|
||||
]);
|
||||
|
||||
const syncSavedSnapshot = useCallback(
|
||||
(
|
||||
nextSummary: string,
|
||||
nextDescription: string,
|
||||
nextSelectedIds: Set<string>,
|
||||
) => {
|
||||
setSavedSummary(nextSummary);
|
||||
setSavedDescription(nextDescription);
|
||||
setSavedSelectedIds(new Set(nextSelectedIds));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]);
|
||||
|
||||
const saveChanges = useCallback(
|
||||
async ({ showToast = true }: { showToast?: boolean } = {}) => {
|
||||
@ -84,9 +131,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
setIsSaving(true);
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
jobDescription,
|
||||
selectedProjectIds: selectedIdsCsv,
|
||||
});
|
||||
syncSavedSnapshot(summary, jobDescription, selectedIds);
|
||||
if (showToast) toast.success("Changes saved");
|
||||
await onUpdate();
|
||||
} catch (error) {
|
||||
@ -96,7 +144,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[job.id, onUpdate, selectedIds, summary, jobDescription],
|
||||
[job.id, onUpdate, selectedIdsCsv, selectedIds, summary, jobDescription, syncSavedSnapshot],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -121,18 +169,17 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
const handleSummarize = async () => {
|
||||
try {
|
||||
setIsSummarizing(true);
|
||||
// Save changes first so AI uses latest description
|
||||
if (isDirty) {
|
||||
await saveChanges({ showToast: false });
|
||||
}
|
||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||
setSummary(updatedJob.tailoredSummary || "");
|
||||
setJobDescription(updatedJob.jobDescription || "");
|
||||
if (updatedJob.selectedProjectIds) {
|
||||
setSelectedIds(
|
||||
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
|
||||
);
|
||||
}
|
||||
const nextSummary = updatedJob.tailoredSummary || "";
|
||||
const nextDescription = updatedJob.jobDescription || "";
|
||||
const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds);
|
||||
setSummary(nextSummary);
|
||||
setJobDescription(nextDescription);
|
||||
setSelectedIds(nextSelectedIds);
|
||||
syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds);
|
||||
toast.success("AI Summary & Projects generated");
|
||||
await onUpdate();
|
||||
} catch (error) {
|
||||
@ -150,7 +197,6 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
if (shouldProceed === false) return;
|
||||
|
||||
setIsGeneratingPdf(true);
|
||||
// Save current state first to ensure PDF uses latest
|
||||
await saveChanges({ showToast: false });
|
||||
|
||||
await api.generateJobPdf(job.id);
|
||||
@ -163,7 +209,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const maxProjects = 3; // Example limit, could come from settings
|
||||
const maxProjects = 3;
|
||||
const tooManyProjects = selectedIds.size > maxProjects;
|
||||
|
||||
return (
|
||||
@ -211,6 +257,12 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={jobDescription}
|
||||
onChange={(e) => setJobDescription(e.target.value)}
|
||||
onFocus={() => setActiveField("description")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) =>
|
||||
prev === "description" ? null : prev,
|
||||
)
|
||||
}
|
||||
placeholder="The raw job description..."
|
||||
/>
|
||||
</div>
|
||||
@ -226,6 +278,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
onFocus={() => setActiveField("summary")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) => (prev === "summary" ? null : prev))
|
||||
}
|
||||
placeholder="AI will generate this, or you can write your own..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -15,12 +15,14 @@ interface DiscoveredPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
onTailoringDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
onTailoringDirtyChange,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("decide");
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
@ -31,7 +33,18 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
setMode("decide");
|
||||
setIsSkipping(false);
|
||||
setIsFinalizing(false);
|
||||
}, []);
|
||||
onTailoringDirtyChange?.(false);
|
||||
}, [job?.id, onTailoringDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "tailor") {
|
||||
onTailoringDirtyChange?.(false);
|
||||
}
|
||||
}, [mode, onTailoringDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onTailoringDirtyChange?.(false);
|
||||
}, [onTailoringDirtyChange]);
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!job) return;
|
||||
@ -102,6 +115,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
onBack={() => setMode("decide")}
|
||||
onFinalize={handleFinalize}
|
||||
isFinalizing={isFinalizing}
|
||||
onDirtyChange={onTailoringDirtyChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { TailorMode } from "./TailorMode";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||
updateJob: vi.fn(),
|
||||
summarizeJob: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
id: "job-1",
|
||||
tailoredSummary: "Saved summary",
|
||||
jobDescription: "Saved description",
|
||||
selectedProjectIds: "p1",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
|
||||
describe("TailorMode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||
const { rerender } = render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TailorMode
|
||||
job={createJob({ tailoredSummary: "Older server value" })}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft");
|
||||
});
|
||||
|
||||
it("resets local state when job id changes", async () => {
|
||||
const { rerender } = render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TailorMode
|
||||
job={createJob({
|
||||
id: "job-2",
|
||||
tailoredSummary: "New job summary",
|
||||
jobDescription: "New job description",
|
||||
selectedProjectIds: "",
|
||||
})}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary");
|
||||
});
|
||||
|
||||
it("does not sync same-job props while summary field is focused", async () => {
|
||||
const { rerender } = render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
const summary = screen.getByLabelText("Tailored Summary");
|
||||
fireEvent.focus(summary);
|
||||
|
||||
rerender(
|
||||
<TailorMode
|
||||
job={createJob({ tailoredSummary: "Incoming from poll" })}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Saved summary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@ -14,94 +14,155 @@ interface TailorModeProps {
|
||||
onBack: () => void;
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
/** Variant controls the finalize button text. Default is 'discovered'. */
|
||||
variant?: "discovered" | "ready";
|
||||
}
|
||||
|
||||
const parseSelectedIds = (value: string | null | undefined) =>
|
||||
new Set(value?.split(",").filter(Boolean) ?? []);
|
||||
|
||||
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
|
||||
if (current.size !== saved.size) return true;
|
||||
for (const id of current) {
|
||||
if (!saved.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
job,
|
||||
onBack,
|
||||
onFinalize,
|
||||
isFinalizing,
|
||||
onDirtyChange,
|
||||
variant = "discovered",
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
});
|
||||
|
||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||
const [savedDescription, setSavedDescription] = useState(job.jobDescription || "");
|
||||
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [draftStatus, setDraftStatus] = useState<
|
||||
"unsaved" | "saving" | "saved"
|
||||
>("saved");
|
||||
const [draftStatus, setDraftStatus] = useState<"unsaved" | "saving" | "saved">(
|
||||
"saved",
|
||||
);
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const [activeField, setActiveField] = useState<"summary" | "description" | null>(
|
||||
null,
|
||||
);
|
||||
const lastJobIdRef = useRef(job.id);
|
||||
|
||||
useEffect(() => {
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSummary(job.tailoredSummary || "");
|
||||
setJobDescription(job.jobDescription || "");
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
setSelectedIds(new Set(saved));
|
||||
setDraftStatus("saved");
|
||||
}, [job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
|
||||
|
||||
const savedSummary = job.tailoredSummary || "";
|
||||
const savedDescription = job.jobDescription || "";
|
||||
const savedIds = useMemo(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
}, [job.selectedProjectIds]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const isDirty = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
if (selectedIds.size !== savedIds.size) return true;
|
||||
for (const id of selectedIds) {
|
||||
if (!savedIds.has(id)) return true;
|
||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingSummary = job.tailoredSummary || "";
|
||||
const incomingDescription = job.jobDescription || "";
|
||||
const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds);
|
||||
|
||||
if (job.id !== lastJobIdRef.current) {
|
||||
lastJobIdRef.current = job.id;
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
setDraftStatus("saved");
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
|
||||
if (isDirty || activeField !== null) return;
|
||||
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
setDraftStatus("saved");
|
||||
}, [
|
||||
summary,
|
||||
savedSummary,
|
||||
jobDescription,
|
||||
savedDescription,
|
||||
selectedIds,
|
||||
savedIds,
|
||||
job.id,
|
||||
job.tailoredSummary,
|
||||
job.jobDescription,
|
||||
job.selectedProjectIds,
|
||||
isDirty,
|
||||
activeField,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChanges && draftStatus === "saved") {
|
||||
if (isDirty && draftStatus === "saved") {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, [hasChanges, draftStatus]);
|
||||
if (!isDirty && draftStatus === "unsaved") {
|
||||
setDraftStatus("saved");
|
||||
}
|
||||
}, [isDirty, draftStatus]);
|
||||
|
||||
const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]);
|
||||
|
||||
const syncSavedSnapshot = useCallback(
|
||||
(
|
||||
nextSummary: string,
|
||||
nextDescription: string,
|
||||
nextSelectedIds: Set<string>,
|
||||
) => {
|
||||
setSavedSummary(nextSummary);
|
||||
setSavedDescription(nextDescription);
|
||||
setSavedSelectedIds(new Set(nextSelectedIds));
|
||||
setDraftStatus("saved");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const persistCurrent = useCallback(async () => {
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription,
|
||||
selectedProjectIds: selectedIdsCsv,
|
||||
});
|
||||
syncSavedSnapshot(summary, jobDescription, selectedIds);
|
||||
}, [job.id, summary, jobDescription, selectedIdsCsv, selectedIds, syncSavedSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChanges || draftStatus !== "unsaved") return;
|
||||
if (!isDirty || draftStatus !== "unsaved") return;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
setDraftStatus("saving");
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
setDraftStatus("saved");
|
||||
await persistCurrent();
|
||||
} catch {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]);
|
||||
}, [isDirty, draftStatus, persistCurrent]);
|
||||
|
||||
const handleToggleProject = useCallback(
|
||||
(id: string) => {
|
||||
@ -120,23 +181,18 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
if (hasChanges) {
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
if (isDirty) {
|
||||
await persistCurrent();
|
||||
}
|
||||
|
||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||
setSummary(updatedJob.tailoredSummary || "");
|
||||
setJobDescription(updatedJob.jobDescription || "");
|
||||
if (updatedJob.selectedProjectIds) {
|
||||
setSelectedIds(
|
||||
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
|
||||
);
|
||||
}
|
||||
setDraftStatus("saved");
|
||||
const nextSummary = updatedJob.tailoredSummary || "";
|
||||
const nextDescription = updatedJob.jobDescription || "";
|
||||
const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds);
|
||||
setSummary(nextSummary);
|
||||
setJobDescription(nextDescription);
|
||||
setSelectedIds(nextSelectedIds);
|
||||
syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds);
|
||||
toast.success("Draft generated with AI", {
|
||||
description: "Review and edit before finalizing.",
|
||||
});
|
||||
@ -150,14 +206,10 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (hasChanges) {
|
||||
if (isDirty) {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
await persistCurrent();
|
||||
} catch {
|
||||
toast.error("Failed to save draft before finalizing");
|
||||
setIsSaving(false);
|
||||
@ -193,7 +245,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
Saving...
|
||||
</>
|
||||
)}
|
||||
{draftStatus === "saved" && !hasChanges && (
|
||||
{draftStatus === "saved" && !isDirty && (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
Saved
|
||||
@ -260,6 +312,12 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={jobDescription}
|
||||
onChange={(event) => setJobDescription(event.target.value)}
|
||||
onFocus={() => setActiveField("description")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) =>
|
||||
prev === "description" ? null : prev,
|
||||
)
|
||||
}
|
||||
placeholder="The raw job description..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
@ -278,6 +336,10 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
onFocus={() => setActiveField("summary")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) => (prev === "summary" ? null : prev))
|
||||
}
|
||||
placeholder="Write a tailored summary for this role, or generate with AI..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
|
||||
@ -158,6 +158,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
isLoading,
|
||||
isPipelineRunning,
|
||||
setIsPipelineRunning,
|
||||
setIsRefreshPaused,
|
||||
loadJobs,
|
||||
} = useOrchestratorData();
|
||||
const enabledSources = useMemo(
|
||||
@ -348,6 +349,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
selectedJob={selectedJob}
|
||||
onSelectJobId={handleSelectJobId}
|
||||
onJobUpdated={loadJobs}
|
||||
onPauseRefreshChange={setIsRefreshPaused}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -381,6 +383,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
selectedJob={selectedJob}
|
||||
onSelectJobId={handleSelectJobId}
|
||||
onJobUpdated={loadJobs}
|
||||
onPauseRefreshChange={setIsRefreshPaused}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
@ -58,7 +58,20 @@ vi.mock("../../components/ReadyPanel", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../components/TailoringEditor", () => ({
|
||||
TailoringEditor: () => <div data-testid="tailoring-editor" />,
|
||||
TailoringEditor: ({
|
||||
onDirtyChange,
|
||||
}: {
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}) => (
|
||||
<div data-testid="tailoring-editor">
|
||||
<button type="button" onClick={() => onDirtyChange?.(true)}>
|
||||
Mark tailoring dirty
|
||||
</button>
|
||||
<button type="button" onClick={() => onDirtyChange?.(false)}>
|
||||
Mark tailoring clean
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", async (importOriginal) => {
|
||||
@ -150,52 +163,62 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderJobDetailPanel = async (
|
||||
props: React.ComponentProps<typeof JobDetailPanel>,
|
||||
) => {
|
||||
const rendered = render(<JobDetailPanel {...props} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
return rendered;
|
||||
};
|
||||
|
||||
describe("JobDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the discovered panel when active tab is discovered", () => {
|
||||
it("renders the discovered panel when active tab is discovered", async () => {
|
||||
const job = createJob({ id: "job-99", status: "discovered" });
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="discovered"
|
||||
activeJobs={[job]}
|
||||
selectedJob={job}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "discovered",
|
||||
activeJobs: [job],
|
||||
selectedJob: job,
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||
});
|
||||
|
||||
it("shows an empty state when no job is selected", () => {
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={null}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
it("shows an empty state when no job is selected", async () => {
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: null,
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText("No job selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a stripped description preview for html content", () => {
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({
|
||||
it("renders a stripped description preview for html content", async () => {
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({
|
||||
jobDescription: "<p>Hello <strong>world</strong></p>",
|
||||
})}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
}),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||
@ -205,14 +228,14 @@ describe("JobDetailPanel", () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ jobDescription: "Original" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={onJobUpdated}
|
||||
/>,
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({ jobDescription: "Original" }),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
||||
@ -236,14 +259,14 @@ describe("JobDetailPanel", () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={onJobUpdated}
|
||||
/>,
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({ status: "ready" }),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /applied/i }));
|
||||
@ -258,14 +281,14 @@ describe("JobDetailPanel", () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.skipJob).mockResolvedValue(undefined as any);
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={onJobUpdated}
|
||||
/>,
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({ status: "ready" }),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(
|
||||
@ -277,4 +300,26 @@ describe("JobDetailPanel", () => {
|
||||
await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1"));
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards tailoring dirty state to refresh pause callback", async () => {
|
||||
const onPauseRefreshChange = vi.fn();
|
||||
|
||||
await renderJobDetailPanel(
|
||||
{
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({ status: "ready" }),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
onPauseRefreshChange,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /tailoring/i }));
|
||||
fireEvent.click(await screen.findByText("Mark tailoring dirty"));
|
||||
fireEvent.click(screen.getByText("Mark tailoring clean"));
|
||||
|
||||
expect(onPauseRefreshChange).toHaveBeenCalledWith(true);
|
||||
expect(onPauseRefreshChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -50,6 +50,7 @@ interface JobDetailPanelProps {
|
||||
selectedJob: Job | null;
|
||||
onSelectJobId: (jobId: string | null) => void;
|
||||
onJobUpdated: () => Promise<void>;
|
||||
onPauseRefreshChange?: (paused: boolean) => void;
|
||||
}
|
||||
|
||||
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
@ -58,6 +59,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
selectedJob,
|
||||
onSelectJobId,
|
||||
onJobUpdated,
|
||||
onPauseRefreshChange,
|
||||
}) => {
|
||||
const [detailTab, setDetailTab] = useState<
|
||||
"overview" | "tailoring" | "description"
|
||||
@ -71,10 +73,23 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
const handleTailoringDirtyChange = useCallback(
|
||||
(isDirty: boolean) => {
|
||||
setHasUnsavedTailoring(isDirty);
|
||||
onPauseRefreshChange?.(isDirty);
|
||||
},
|
||||
[onPauseRefreshChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
}, []);
|
||||
onPauseRefreshChange?.(false);
|
||||
}, [selectedJob?.id, onPauseRefreshChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onPauseRefreshChange?.(false);
|
||||
}, [onPauseRefreshChange]);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!selectedJob?.jobDescription) return "No description available.";
|
||||
@ -275,6 +290,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onTailoringDirtyChange={handleTailoringDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -285,6 +301,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onTailoringDirtyChange={handleTailoringDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -534,7 +551,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
<TailoringEditor
|
||||
job={selectedJob}
|
||||
onUpdate={onJobUpdated}
|
||||
onDirtyChange={setHasUnsavedTailoring}
|
||||
onDirtyChange={handleTailoringDirtyChange}
|
||||
onRegisterSave={(save) => {
|
||||
saveTailoringRef.current = save;
|
||||
}}
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { useOrchestratorData } from "./useOrchestratorData";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
getJobs: vi.fn(),
|
||||
getPipelineStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeResponse = (jobId: string) => ({
|
||||
jobs: [{ id: jobId }],
|
||||
total: 1,
|
||||
byStatus: {
|
||||
discovered: 1,
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
},
|
||||
});
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
const deferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe("useOrchestratorData", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.mocked(api.getJobs).mockResolvedValue(makeResponse("initial") as any);
|
||||
vi.mocked(api.getPipelineStatus).mockResolvedValue({ isRunning: false } as any);
|
||||
});
|
||||
|
||||
it("applies newest loadJobs response when requests resolve out of order", async () => {
|
||||
const { result } = renderHook(() => useOrchestratorData());
|
||||
|
||||
await waitFor(() => {
|
||||
expect((result.current.jobs[0] as any)?.id).toBe("initial");
|
||||
});
|
||||
|
||||
const first = deferred<any>();
|
||||
const second = deferred<any>();
|
||||
vi.mocked(api.getJobs)
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => second.promise);
|
||||
|
||||
act(() => {
|
||||
void result.current.loadJobs();
|
||||
void result.current.loadJobs();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(makeResponse("newest"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((result.current.jobs[0] as any)?.id).toBe("newest");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(makeResponse("stale"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect((result.current.jobs[0] as any)?.id).toBe("newest");
|
||||
});
|
||||
|
||||
it("pauses and resumes polling based on isRefreshPaused", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(api.getJobs).mockResolvedValue(makeResponse("steady") as any);
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorData());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(api.getJobs).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
result.current.setIsRefreshPaused(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const pausedBaselineCalls = vi.mocked(api.getJobs).mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(api.getJobs).toHaveBeenCalledTimes(pausedBaselineCalls);
|
||||
|
||||
act(() => {
|
||||
result.current.setIsRefreshPaused(false);
|
||||
});
|
||||
|
||||
const resumedBaselineCalls = vi.mocked(api.getJobs).mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(vi.mocked(api.getJobs).mock.calls.length).toBeGreaterThan(
|
||||
resumedBaselineCalls,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Job, JobStatus } from "@shared/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as api from "../../api";
|
||||
|
||||
@ -17,19 +17,31 @@ export const useOrchestratorData = () => {
|
||||
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
|
||||
const [isRefreshPaused, setIsRefreshPaused] = useState(false);
|
||||
const requestSeqRef = useRef(0);
|
||||
const latestAppliedSeqRef = useRef(0);
|
||||
const pendingLoadCountRef = useRef(0);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
const seq = ++requestSeqRef.current;
|
||||
pendingLoadCountRef.current += 1;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getJobs();
|
||||
setJobs(data.jobs);
|
||||
setStats(data.byStatus);
|
||||
if (seq >= latestAppliedSeqRef.current) {
|
||||
latestAppliedSeqRef.current = seq;
|
||||
setJobs(data.jobs);
|
||||
setStats(data.byStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load jobs";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
pendingLoadCountRef.current = Math.max(0, pendingLoadCountRef.current - 1);
|
||||
if (pendingLoadCountRef.current === 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -47,12 +59,13 @@ export const useOrchestratorData = () => {
|
||||
checkPipelineStatus();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (isRefreshPaused) return;
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadJobs, checkPipelineStatus]);
|
||||
}, [loadJobs, checkPipelineStatus, isRefreshPaused]);
|
||||
|
||||
return {
|
||||
jobs,
|
||||
@ -60,6 +73,8 @@ export const useOrchestratorData = () => {
|
||||
isLoading,
|
||||
isPipelineRunning,
|
||||
setIsPipelineRunning,
|
||||
isRefreshPaused,
|
||||
setIsRefreshPaused,
|
||||
loadJobs,
|
||||
checkPipelineStatus,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user