Select multiple jobs for bulk actions (#94)

* Initial implementation

* UI

* UI

* floating bar animations

* UI and split up orchestrator page

* remove bulk action hint

* UI

* UI

* formatting

* fix lint

* enforce max actions
This commit is contained in:
Shaheer Sarfaraz 2026-02-07 14:39:49 +00:00 committed by GitHub
parent 855ac0c5a5
commit cfabee5f45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1241 additions and 149 deletions

View File

@ -8,6 +8,8 @@ import type {
ApplicationTask, ApplicationTask,
AppSettings, AppSettings,
BackupInfo, BackupInfo,
BulkJobActionRequest,
BulkJobActionResponse,
CreateJobInput, CreateJobInput,
DemoInfoResponse, DemoInfoResponse,
Job, Job,
@ -227,6 +229,15 @@ export async function skipJob(id: string): Promise<Job> {
}); });
} }
export async function bulkJobAction(
input: BulkJobActionRequest,
): Promise<BulkJobActionResponse> {
return fetchApi<BulkJobActionResponse>("/jobs/bulk-actions", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function getJobStageEvents(id: string): Promise<StageEvent[]> { export async function getJobStageEvents(id: string): Promise<StageEvent[]> {
return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`); return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`);
} }

View File

@ -23,7 +23,7 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import type React from "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 { toast } from "sonner";
import { import {
Accordion, Accordion,
@ -75,6 +75,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
employer: string; employer: string;
timeoutId: ReturnType<typeof setTimeout>; timeoutId: ReturnType<typeof setTimeout>;
} | null>(null); } | null>(null);
const previousJobIdRef = useRef<string | null>(null);
const { personName } = useProfile(); const { personName } = useProfile();
@ -85,6 +86,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// Reset mode when job changes // Reset mode when job changes
useEffect(() => { useEffect(() => {
const currentJobId = job?.id ?? null;
if (previousJobIdRef.current === currentJobId) return;
previousJobIdRef.current = currentJobId;
setMode("ready"); setMode("ready");
onTailoringDirtyChange?.(false); onTailoringDirtyChange?.(false);
}, [job?.id, onTailoringDirtyChange]); }, [job?.id, onTailoringDirtyChange]);

View File

@ -51,7 +51,9 @@ describe("TailoringEditor", () => {
/>, />,
); );
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft"); expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Local draft",
);
}); });
it("resets local state when job id changes", async () => { it("resets local state when job id changes", async () => {
@ -78,7 +80,9 @@ describe("TailoringEditor", () => {
/>, />,
); );
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary"); expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"New job summary",
);
}); });
it("emits dirty state changes", async () => { it("emits dirty state changes", async () => {

View File

@ -42,28 +42,39 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}) => { }) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]); const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || ""); const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds), parseSelectedIds(job.selectedProjectIds),
); );
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedDescription, setSavedDescription] = useState(job.jobDescription || ""); const [savedDescription, setSavedDescription] = useState(
job.jobDescription || "",
);
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() => const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds), parseSelectedIds(job.selectedProjectIds),
); );
const [isSummarizing, setIsSummarizing] = useState(false); const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [activeField, setActiveField] = useState<"summary" | "description" | null>( const [activeField, setActiveField] = useState<
null, "summary" | "description" | null
); >(null);
const lastJobIdRef = useRef(job.id); const lastJobIdRef = useRef(job.id);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (summary !== savedSummary) return true; if (summary !== savedSummary) return true;
if (jobDescription !== savedDescription) return true; if (jobDescription !== savedDescription) return true;
return hasSelectionDiff(selectedIds, savedSelectedIds); return hasSelectionDiff(selectedIds, savedSelectedIds);
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]); }, [
summary,
savedSummary,
jobDescription,
savedDescription,
selectedIds,
savedSelectedIds,
]);
useEffect(() => { useEffect(() => {
onDirtyChange?.(isDirty); onDirtyChange?.(isDirty);
@ -123,7 +134,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
[], [],
); );
const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]); const selectedIdsCsv = useMemo(
() => Array.from(selectedIds).join(","),
[selectedIds],
);
const saveChanges = useCallback( const saveChanges = useCallback(
async ({ showToast = true }: { showToast?: boolean } = {}) => { async ({ showToast = true }: { showToast?: boolean } = {}) => {
@ -144,7 +158,15 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
setIsSaving(false); setIsSaving(false);
} }
}, },
[job.id, onUpdate, selectedIdsCsv, selectedIds, summary, jobDescription, syncSavedSnapshot], [
job.id,
onUpdate,
selectedIdsCsv,
selectedIds,
summary,
jobDescription,
syncSavedSnapshot,
],
); );
useEffect(() => { useEffect(() => {
@ -259,9 +281,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
onChange={(e) => setJobDescription(e.target.value)} onChange={(e) => setJobDescription(e.target.value)}
onFocus={() => setActiveField("description")} onFocus={() => setActiveField("description")}
onBlur={() => onBlur={() =>
setActiveField((prev) => setActiveField((prev) => (prev === "description" ? null : prev))
prev === "description" ? null : prev,
)
} }
placeholder="The raw job description..." placeholder="The raw job description..."
/> />

View File

@ -1,6 +1,6 @@
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import * as api from "../../api"; import * as api from "../../api";
import { useRescoreJob } from "../../hooks/useRescoreJob"; import { useRescoreJob } from "../../hooks/useRescoreJob";
@ -27,9 +27,13 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
const [mode, setMode] = useState<PanelMode>("decide"); const [mode, setMode] = useState<PanelMode>("decide");
const [isSkipping, setIsSkipping] = useState(false); const [isSkipping, setIsSkipping] = useState(false);
const [isFinalizing, setIsFinalizing] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false);
const previousJobIdRef = useRef<string | null>(null);
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated); const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
useEffect(() => { useEffect(() => {
const currentJobId = job?.id ?? null;
if (previousJobIdRef.current === currentJobId) return;
previousJobIdRef.current = currentJobId;
setMode("decide"); setMode("decide");
setIsSkipping(false); setIsSkipping(false);
setIsFinalizing(false); setIsFinalizing(false);

View File

@ -57,7 +57,9 @@ describe("TailorMode", () => {
/>, />,
); );
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft"); expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Local draft",
);
}); });
it("resets local state when job id changes", async () => { it("resets local state when job id changes", async () => {
@ -91,7 +93,9 @@ describe("TailorMode", () => {
/>, />,
); );
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary"); expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"New job summary",
);
}); });
it("does not sync same-job props while summary field is focused", async () => { it("does not sync same-job props while summary field is focused", async () => {

View File

@ -40,26 +40,30 @@ export const TailorMode: React.FC<TailorModeProps> = ({
}) => { }) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]); const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || ""); const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds), parseSelectedIds(job.selectedProjectIds),
); );
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedDescription, setSavedDescription] = useState(job.jobDescription || ""); const [savedDescription, setSavedDescription] = useState(
job.jobDescription || "",
);
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() => const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds), parseSelectedIds(job.selectedProjectIds),
); );
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [draftStatus, setDraftStatus] = useState<"unsaved" | "saving" | "saved">( const [draftStatus, setDraftStatus] = useState<
"saved", "unsaved" | "saving" | "saved"
); >("saved");
const [showDescription, setShowDescription] = useState(false); const [showDescription, setShowDescription] = useState(false);
const [activeField, setActiveField] = useState<"summary" | "description" | null>( const [activeField, setActiveField] = useState<
null, "summary" | "description" | null
); >(null);
const lastJobIdRef = useRef(job.id); const lastJobIdRef = useRef(job.id);
useEffect(() => { useEffect(() => {
@ -70,7 +74,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
if (summary !== savedSummary) return true; if (summary !== savedSummary) return true;
if (jobDescription !== savedDescription) return true; if (jobDescription !== savedDescription) return true;
return hasSelectionDiff(selectedIds, savedSelectedIds); return hasSelectionDiff(selectedIds, savedSelectedIds);
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]); }, [
summary,
savedSummary,
jobDescription,
savedDescription,
selectedIds,
savedSelectedIds,
]);
useEffect(() => { useEffect(() => {
onDirtyChange?.(isDirty); onDirtyChange?.(isDirty);
@ -124,7 +135,10 @@ export const TailorMode: React.FC<TailorModeProps> = ({
} }
}, [isDirty, draftStatus]); }, [isDirty, draftStatus]);
const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]); const selectedIdsCsv = useMemo(
() => Array.from(selectedIds).join(","),
[selectedIds],
);
const syncSavedSnapshot = useCallback( const syncSavedSnapshot = useCallback(
( (
@ -147,7 +161,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
selectedProjectIds: selectedIdsCsv, selectedProjectIds: selectedIdsCsv,
}); });
syncSavedSnapshot(summary, jobDescription, selectedIds); syncSavedSnapshot(summary, jobDescription, selectedIds);
}, [job.id, summary, jobDescription, selectedIdsCsv, selectedIds, syncSavedSnapshot]); }, [
job.id,
summary,
jobDescription,
selectedIdsCsv,
selectedIds,
syncSavedSnapshot,
]);
useEffect(() => { useEffect(() => {
if (!isDirty || draftStatus !== "unsaved") return; if (!isDirty || draftStatus !== "unsaved") return;
@ -314,9 +335,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onChange={(event) => setJobDescription(event.target.value)} onChange={(event) => setJobDescription(event.target.value)}
onFocus={() => setActiveField("description")} onFocus={() => setActiveField("description")}
onBlur={() => onBlur={() =>
setActiveField((prev) => setActiveField((prev) => (prev === "description" ? null : prev))
prev === "description" ? null : prev,
)
} }
placeholder="The raw job description..." placeholder="The raw job description..."
disabled={disableInputs} disabled={disableInputs}

View File

@ -14,11 +14,13 @@ import * as api from "../api";
import { ManualImportSheet } from "../components"; import { ManualImportSheet } from "../components";
import type { FilterTab, JobSort } from "./orchestrator/constants"; import type { FilterTab, JobSort } from "./orchestrator/constants";
import { DEFAULT_SORT } from "./orchestrator/constants"; import { DEFAULT_SORT } from "./orchestrator/constants";
import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar";
import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
import { JobListPanel } from "./orchestrator/JobListPanel"; import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters"; import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader"; import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary"; import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { useBulkJobSelection } from "./orchestrator/useBulkJobSelection";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { usePipelineSources } from "./orchestrator/usePipelineSources"; import { usePipelineSources } from "./orchestrator/usePipelineSources";
@ -184,6 +186,20 @@ export const OrchestratorPage: React.FC = () => {
: null, : null,
[jobs, selectedJobId], [jobs, selectedJobId],
); );
const {
selectedJobIds,
canSkipSelected,
canMoveSelected,
bulkActionInFlight,
toggleSelectJob,
toggleSelectAll,
clearSelection,
runBulkAction,
} = useBulkJobSelection({
activeJobs,
activeTab,
loadJobs,
});
useEffect(() => { useEffect(() => {
if (isLoading || sourceFilter === "all") return; if (isLoading || sourceFilter === "all") return;
@ -335,9 +351,12 @@ export const OrchestratorPage: React.FC = () => {
jobs={jobs} jobs={jobs}
activeJobs={activeJobs} activeJobs={activeJobs}
selectedJobId={selectedJobId} selectedJobId={selectedJobId}
selectedJobIds={selectedJobIds}
activeTab={activeTab} activeTab={activeTab}
searchQuery={searchQuery} searchQuery={searchQuery}
onSelectJob={handleSelectJob} onSelectJob={handleSelectJob}
onToggleSelectJob={toggleSelectJob}
onToggleSelectAll={toggleSelectAll}
/> />
{/* Inspector panel: visually subordinate to list */} {/* Inspector panel: visually subordinate to list */}
@ -357,6 +376,16 @@ export const OrchestratorPage: React.FC = () => {
</section> </section>
</main> </main>
<FloatingBulkActionsBar
selectedCount={selectedJobIds.size}
canMoveSelected={canMoveSelected}
canSkipSelected={canSkipSelected}
bulkActionInFlight={bulkActionInFlight !== null}
onMoveToReady={() => void runBulkAction("move_to_ready")}
onSkipSelected={() => void runBulkAction("skip")}
onClear={clearSelection}
/>
<ManualImportSheet <ManualImportSheet
open={isManualImportOpen} open={isManualImportOpen}
onOpenChange={setIsManualImportOpen} onOpenChange={setIsManualImportOpen}

View File

@ -0,0 +1,88 @@
import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FloatingBulkActionsBarProps {
selectedCount: number;
canMoveSelected: boolean;
canSkipSelected: boolean;
bulkActionInFlight: boolean;
onMoveToReady: () => void;
onSkipSelected: () => void;
onClear: () => void;
}
export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
selectedCount,
canMoveSelected,
canSkipSelected,
bulkActionInFlight,
onMoveToReady,
onSkipSelected,
onClear,
}) => {
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (selectedCount > 0) {
setIsMounted(true);
const enterTimer = window.setTimeout(() => setIsVisible(true), 10);
return () => window.clearTimeout(enterTimer);
}
setIsVisible(false);
const exitTimer = window.setTimeout(() => setIsMounted(false), 180);
return () => window.clearTimeout(exitTimer);
}, [selectedCount]);
if (!isMounted) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div
className={cn(
"pointer-events-auto flex flex-wrap items-center gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85",
"transition-all duration-200 ease-out",
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
)}
>
<div className="text-xs text-muted-foreground tabular-nums">
{selectedCount} selected
</div>
{canMoveSelected && (
<Button
type="button"
size="sm"
variant="outline"
disabled={bulkActionInFlight}
onClick={onMoveToReady}
>
Move to Ready
</Button>
)}
{canSkipSelected && (
<Button
type="button"
size="sm"
variant="outline"
disabled={bulkActionInFlight}
onClick={onSkipSelected}
>
Skip selected
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={onClear}
disabled={bulkActionInFlight}
>
Clear
</Button>
</div>
</div>
);
};

View File

@ -1,5 +1,11 @@
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type React from "react"; import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api"; import * as api from "../../api";
@ -181,45 +187,39 @@ describe("JobDetailPanel", () => {
it("renders the discovered panel when active tab is discovered", async () => { it("renders the discovered panel when active tab is discovered", async () => {
const job = createJob({ id: "job-99", status: "discovered" }); const job = createJob({ id: "job-99", status: "discovered" });
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "discovered",
activeTab: "discovered", activeJobs: [job],
activeJobs: [job], selectedJob: job,
selectedJob: job, onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated: vi.fn().mockResolvedValue(undefined),
onJobUpdated: vi.fn().mockResolvedValue(undefined), });
},
);
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99"); expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
}); });
it("shows an empty state when no job is selected", async () => { it("shows an empty state when no job is selected", async () => {
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "all",
activeTab: "all", activeJobs: [],
activeJobs: [], selectedJob: null,
selectedJob: null, onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated: vi.fn().mockResolvedValue(undefined),
onJobUpdated: vi.fn().mockResolvedValue(undefined), });
},
);
expect(screen.getByText("No job selected")).toBeInTheDocument(); expect(screen.getByText("No job selected")).toBeInTheDocument();
}); });
it("renders a stripped description preview for html content", async () => { it("renders a stripped description preview for html content", async () => {
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "all",
activeTab: "all", activeJobs: [],
activeJobs: [], selectedJob: createJob({
selectedJob: createJob({ jobDescription: "<p>Hello <strong>world</strong></p>",
jobDescription: "<p>Hello <strong>world</strong></p>", }),
}), onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated: vi.fn().mockResolvedValue(undefined),
onJobUpdated: vi.fn().mockResolvedValue(undefined), });
},
);
expect(screen.getByText("Hello world")).toBeInTheDocument(); expect(screen.getByText("Hello world")).toBeInTheDocument();
}); });
@ -228,15 +228,13 @@ describe("JobDetailPanel", () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined); const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.updateJob).mockResolvedValue(undefined as any); vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "all",
activeTab: "all", activeJobs: [],
activeJobs: [], selectedJob: createJob({ jobDescription: "Original" }),
selectedJob: createJob({ jobDescription: "Original" }), onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated,
onJobUpdated, });
},
);
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
fireEvent.click(await screen.findByRole("button", { name: /^edit$/i })); fireEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
@ -259,15 +257,13 @@ describe("JobDetailPanel", () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined); const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any); vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "all",
activeTab: "all", activeJobs: [],
activeJobs: [], selectedJob: createJob({ status: "ready" }),
selectedJob: createJob({ status: "ready" }), onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated,
onJobUpdated, });
},
);
fireEvent.click(screen.getByRole("button", { name: /applied/i })); fireEvent.click(screen.getByRole("button", { name: /applied/i }));
@ -281,15 +277,13 @@ describe("JobDetailPanel", () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined); const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.skipJob).mockResolvedValue(undefined as any); vi.mocked(api.skipJob).mockResolvedValue(undefined as any);
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "all",
activeTab: "all", activeJobs: [],
activeJobs: [], selectedJob: createJob({ status: "ready" }),
selectedJob: createJob({ status: "ready" }), onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated,
onJobUpdated, });
},
);
fireEvent.pointerDown( fireEvent.pointerDown(
screen.getByRole("button", { name: /more actions/i }), screen.getByRole("button", { name: /more actions/i }),
@ -304,16 +298,14 @@ describe("JobDetailPanel", () => {
it("forwards tailoring dirty state to refresh pause callback", async () => { it("forwards tailoring dirty state to refresh pause callback", async () => {
const onPauseRefreshChange = vi.fn(); const onPauseRefreshChange = vi.fn();
await renderJobDetailPanel( await renderJobDetailPanel({
{ activeTab: "all",
activeTab: "all", activeJobs: [],
activeJobs: [], selectedJob: createJob({ status: "ready" }),
selectedJob: createJob({ status: "ready" }), onSelectJobId: vi.fn(),
onSelectJobId: vi.fn(), onJobUpdated: vi.fn().mockResolvedValue(undefined),
onJobUpdated: vi.fn().mockResolvedValue(undefined), onPauseRefreshChange,
onPauseRefreshChange, });
},
);
fireEvent.mouseDown(screen.getByRole("tab", { name: /tailoring/i })); fireEvent.mouseDown(screen.getByRole("tab", { name: /tailoring/i }));
fireEvent.click(await screen.findByText("Mark tailoring dirty")); fireEvent.click(await screen.findByText("Mark tailoring dirty"));

View File

@ -70,6 +70,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false); const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null); const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null); const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
const previousSelectedJobIdRef = useRef<string | null>(null);
const { personName } = useProfile(); const { personName } = useProfile();
@ -82,6 +83,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
); );
useEffect(() => { useEffect(() => {
const currentJobId = selectedJob?.id ?? null;
if (previousSelectedJobIdRef.current === currentJobId) return;
previousSelectedJobIdRef.current = currentJobId;
setHasUnsavedTailoring(false); setHasUnsavedTailoring(false);
saveTailoringRef.current = null; saveTailoringRef.current = null;
onPauseRefreshChange?.(false); onPauseRefreshChange?.(false);

View File

@ -74,9 +74,12 @@ describe("JobListPanel", () => {
jobs={[]} jobs={[]}
activeJobs={[]} activeJobs={[]}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
searchQuery="" searchQuery=""
onSelectJob={vi.fn()} onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>, />,
); );
@ -90,9 +93,12 @@ describe("JobListPanel", () => {
jobs={[]} jobs={[]}
activeJobs={[]} activeJobs={[]}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
searchQuery="" searchQuery=""
onSelectJob={vi.fn()} onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>, />,
); );
@ -109,9 +115,12 @@ describe("JobListPanel", () => {
jobs={[]} jobs={[]}
activeJobs={[]} activeJobs={[]}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
searchQuery="iOS" searchQuery="iOS"
onSelectJob={vi.fn()} onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>, />,
); );
@ -120,6 +129,8 @@ describe("JobListPanel", () => {
it("renders jobs and notifies when a job is selected", () => { it("renders jobs and notifies when a job is selected", () => {
const onSelectJob = vi.fn(); const onSelectJob = vi.fn();
const onToggleSelectJob = vi.fn();
const onToggleSelectAll = vi.fn();
const jobs = [ const jobs = [
createJob({ id: "job-1", title: "Backend Engineer" }), createJob({ id: "job-1", title: "Backend Engineer" }),
createJob({ createJob({
@ -135,9 +146,12 @@ describe("JobListPanel", () => {
jobs={jobs} jobs={jobs}
activeJobs={jobs} activeJobs={jobs}
selectedJobId="job-1" selectedJobId="job-1"
selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
searchQuery="" searchQuery=""
onSelectJob={onSelectJob} onSelectJob={onSelectJob}
onToggleSelectJob={onToggleSelectJob}
onToggleSelectAll={onToggleSelectAll}
/>, />,
); );
@ -148,4 +162,34 @@ describe("JobListPanel", () => {
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i })); fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
expect(onSelectJob).toHaveBeenCalledWith("job-2"); expect(onSelectJob).toHaveBeenCalledWith("job-2");
}); });
it("toggles row selection and select-all", () => {
const onToggleSelectJob = vi.fn();
const onToggleSelectAll = vi.fn();
const jobs = [
createJob({ id: "job-1", title: "Backend Engineer" }),
createJob({ id: "job-2", title: "Frontend Engineer" }),
];
render(
<JobListPanel
isLoading={false}
jobs={jobs}
activeJobs={jobs}
selectedJobId="job-1"
selectedJobIds={new Set(["job-1"])}
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
onToggleSelectJob={onToggleSelectJob}
onToggleSelectAll={onToggleSelectAll}
/>,
);
fireEvent.click(screen.getByLabelText("Select Backend Engineer"));
expect(onToggleSelectJob).toHaveBeenCalledWith("job-1");
fireEvent.click(screen.getByLabelText("Select all filtered jobs"));
expect(onToggleSelectAll).toHaveBeenCalledWith(true);
});
}); });

View File

@ -1,6 +1,7 @@
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type React from "react"; import type React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants"; import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
@ -10,9 +11,12 @@ interface JobListPanelProps {
jobs: Job[]; jobs: Job[];
activeJobs: Job[]; activeJobs: Job[];
selectedJobId: string | null; selectedJobId: string | null;
selectedJobIds: Set<string>;
activeTab: FilterTab; activeTab: FilterTab;
searchQuery: string; searchQuery: string;
onSelectJob: (jobId: string) => void; onSelectJob: (jobId: string) => void;
onToggleSelectJob: (jobId: string) => void;
onToggleSelectAll: (checked: boolean) => void;
} }
export const JobListPanel: React.FC<JobListPanelProps> = ({ export const JobListPanel: React.FC<JobListPanelProps> = ({
@ -20,9 +24,12 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
jobs, jobs,
activeJobs, activeJobs,
selectedJobId, selectedJobId,
selectedJobIds,
activeTab, activeTab,
searchQuery, searchQuery,
onSelectJob, onSelectJob,
onToggleSelectJob,
onToggleSelectAll,
}) => ( }) => (
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm"> <div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
{isLoading && jobs.length === 0 ? ( {isLoading && jobs.length === 0 ? (
@ -41,24 +48,64 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
</div> </div>
) : ( ) : (
<div className="divide-y divide-border/40"> <div className="divide-y divide-border/40">
<div className="flex items-center justify-between gap-3 px-4 py-2 opacity-50 hover:opacity-100 transition-opacity">
<label
htmlFor="job-list-select-all"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<Checkbox
id="job-list-select-all"
checked={
activeJobs.length > 0 &&
activeJobs.every((job) => selectedJobIds.has(job.id))
}
onCheckedChange={() => {
const allSelected =
activeJobs.length > 0 &&
activeJobs.every((job) => selectedJobIds.has(job.id));
onToggleSelectAll(!allSelected);
}}
aria-label="Select all filtered jobs"
/>
Select all filtered
</label>
<span className="text-xs text-muted-foreground tabular-nums">
{selectedJobIds.size} selected
</span>
</div>
{activeJobs.map((job) => { {activeJobs.map((job) => {
const isSelected = job.id === selectedJobId; const isSelected = job.id === selectedJobId;
const isChecked = selectedJobIds.has(job.id);
const hasScore = job.suitabilityScore != null; const hasScore = job.suitabilityScore != null;
const statusToken = statusTokens[job.status] ?? defaultStatusToken; const statusToken = statusTokens[job.status] ?? defaultStatusToken;
return ( return (
<button <div
key={job.id} key={job.id}
type="button"
onClick={() => onSelectJob(job.id)}
data-testid={`select-${job.id}`}
className={cn( className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors", "group flex items-center gap-3 px-4 py-3 transition-colors cursor-pointer border-l-2 border-b",
isChecked
? "!border-l !border-l-primary !bg-muted/40"
: "border-l border-l-border/40",
isSelected isSelected
? "bg-primary/5 border-l-2 border-l-primary" ? "bg-primary/5"
: "hover:bg-muted/20 border-l-2 border-l-transparent", : "border-b-border/40 hover:bg-muted/20",
isChecked && isSelected && "outline-2 outline-primary/30",
)} )}
aria-pressed={isSelected}
> >
<Checkbox
checked={isChecked}
onCheckedChange={() => onToggleSelectJob(job.id)}
onClick={(event) => event.stopPropagation()}
aria-label={`Select ${job.title}`}
className={cn(
"border-border/80 cursor-pointer text-muted-foreground/70 transition-opacity",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/20 data-[state=checked]:text-primary",
"data-[state=checked]:shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]",
isChecked || isSelected
? "opacity-100"
: "opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100",
)}
/>
{/* Single status indicator: subtle dot */} {/* Single status indicator: subtle dot */}
<span <span
className={cn( className={cn(
@ -69,49 +116,57 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
title={statusToken.label} title={statusToken.label}
/> />
{/* Primary content: title strongest, company secondary */} <button
<div className="min-w-0 flex-1"> type="button"
<div onClick={() => onSelectJob(job.id)}
className={cn( data-testid={`select-${job.id}`}
"truncate text-sm leading-tight", className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
isSelected ? "font-semibold" : "font-medium", aria-pressed={isSelected}
)} >
> {/* Primary content: title strongest, company secondary */}
{job.title} <div className="min-w-0 flex-1">
</div> <div
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && (
<span className="before:content-['_in_']">
{job.location}
</span>
)}
</div>
{job.salary?.trim() && (
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.salary}
</div>
)}
</div>
{/* Single triage cue: score only (status shown via dot) */}
{hasScore && (
<div className="shrink-0 text-right">
<span
className={cn( className={cn(
"text-xs tabular-nums", "truncate text-sm leading-tight",
(job.suitabilityScore ?? 0) >= 70 isSelected ? "font-semibold" : "font-medium",
? "text-emerald-400/90"
: (job.suitabilityScore ?? 0) >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)} )}
> >
{job.suitabilityScore} {job.title}
</span> </div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && (
<span className="before:content-['_in_']">
{job.location}
</span>
)}
</div>
{job.salary?.trim() && (
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.salary}
</div>
)}
</div> </div>
)}
</button> {/* Single triage cue: score only (status shown via dot) */}
{hasScore && (
<div className="shrink-0 text-right">
<span
className={cn(
"text-xs tabular-nums",
(job.suitabilityScore ?? 0) >= 70
? "text-emerald-400/90"
: (job.suitabilityScore ?? 0) >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)}
>
{job.suitabilityScore}
</span>
</div>
)}
</button>
</div>
); );
})} })}
</div> </div>

View File

@ -0,0 +1,112 @@
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
import { describe, expect, it } from "vitest";
import {
canBulkMoveToReady,
canBulkSkip,
getFailedJobIds,
} from "./bulkActions";
function createJob(id: string, status: JobStatus): Job {
return {
id,
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Role",
employer: "Acme",
employerUrl: null,
jobUrl: `https://example.com/${id}`,
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: null,
status,
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
};
}
describe("bulkActions", () => {
it("computes eligibility for skip and move-to-ready", () => {
expect(
canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]),
).toBe(true);
expect(canBulkSkip([createJob("1", "applied")])).toBe(false);
expect(
canBulkMoveToReady([
createJob("1", "discovered"),
createJob("2", "discovered"),
]),
).toBe(true);
expect(canBulkMoveToReady([createJob("1", "ready")])).toBe(false);
});
it("extracts failed job ids from a bulk response", () => {
const response: BulkJobActionResponse = {
action: "skip",
requested: 3,
succeeded: 1,
failed: 2,
results: [
{ jobId: "job-1", ok: true, job: createJob("job-1", "skipped") },
{
jobId: "job-2",
ok: false,
error: { code: "INVALID_REQUEST", message: "bad status" },
},
{
jobId: "job-3",
ok: false,
error: { code: "NOT_FOUND", message: "missing" },
},
],
};
expect(Array.from(getFailedJobIds(response))).toEqual(["job-2", "job-3"]);
});
});

View File

@ -0,0 +1,20 @@
import type { BulkJobActionResponse, Job } from "@shared/types";
const SKIPPABLE_STATUSES = new Set(["discovered", "ready"]);
export function canBulkSkip(jobs: Job[]): boolean {
return (
jobs.length > 0 && jobs.every((job) => SKIPPABLE_STATUSES.has(job.status))
);
}
export function canBulkMoveToReady(jobs: Job[]): boolean {
return jobs.length > 0 && jobs.every((job) => job.status === "discovered");
}
export function getFailedJobIds(response: BulkJobActionResponse): Set<string> {
const failedIds = response.results
.filter((result) => !result.ok)
.map((result) => result.jobId);
return new Set(failedIds);
}

View File

@ -0,0 +1,201 @@
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
import { useBulkJobSelection } from "./useBulkJobSelection";
vi.mock("../../api", () => ({
bulkJobAction: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));
function createJob(id: string, status: JobStatus): Job {
return {
id,
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: `Role ${id}`,
employer: "Acme",
employerUrl: null,
jobUrl: `https://example.com/${id}`,
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: null,
status,
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
};
}
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("useBulkJobSelection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("caps select-all to the API max", () => {
const activeJobs = Array.from({ length: 101 }, (_, index) =>
createJob(`job-${index + 1}`, "discovered"),
);
const loadJobs = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useBulkJobSelection({
activeJobs,
activeTab: "discovered",
loadJobs,
}),
);
act(() => {
result.current.toggleSelectAll(true);
});
expect(result.current.selectedJobIds.size).toBe(100);
});
it("does not send bulk requests above the max selection size", async () => {
const activeJobs = Array.from({ length: 101 }, (_, index) =>
createJob(`job-${index + 1}`, "discovered"),
);
const loadJobs = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useBulkJobSelection({
activeJobs,
activeTab: "discovered",
loadJobs,
}),
);
act(() => {
for (const job of activeJobs) {
result.current.toggleSelectJob(job.id);
}
});
await act(async () => {
await result.current.runBulkAction("skip");
});
expect(api.bulkJobAction).not.toHaveBeenCalled();
});
it("reconciles failures with selection changes made during in-flight action", async () => {
const activeJobs = [
createJob("job-1", "discovered"),
createJob("job-2", "discovered"),
createJob("job-3", "discovered"),
];
const loadJobs = vi.fn().mockResolvedValue(undefined);
const pending = deferred<BulkJobActionResponse>();
vi.mocked(api.bulkJobAction).mockImplementation(() => pending.promise);
const { result } = renderHook(() =>
useBulkJobSelection({
activeJobs,
activeTab: "discovered",
loadJobs,
}),
);
act(() => {
result.current.toggleSelectJob("job-1");
result.current.toggleSelectJob("job-2");
});
let runPromise: Promise<void>;
await act(async () => {
runPromise = result.current.runBulkAction("skip");
});
act(() => {
result.current.toggleSelectJob("job-2");
result.current.toggleSelectJob("job-3");
});
await act(async () => {
pending.resolve({
action: "skip",
requested: 2,
succeeded: 1,
failed: 1,
results: [
{ jobId: "job-1", ok: true, job: createJob("job-1", "skipped") },
{
jobId: "job-2",
ok: false,
error: { code: "INVALID_REQUEST", message: "bad status" },
},
],
});
await runPromise;
});
await waitFor(() => {
expect(Array.from(result.current.selectedJobIds)).toEqual(["job-3"]);
});
});
});

View File

@ -0,0 +1,163 @@
import type { BulkJobAction, Job } from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import * as api from "../../api";
import {
canBulkMoveToReady,
canBulkSkip,
getFailedJobIds,
} from "./bulkActions";
import type { FilterTab } from "./constants";
const MAX_BULK_ACTION_JOB_IDS = 100;
interface UseBulkJobSelectionArgs {
activeJobs: Job[];
activeTab: FilterTab;
loadJobs: () => Promise<void>;
}
export function useBulkJobSelection({
activeJobs,
activeTab,
loadJobs,
}: UseBulkJobSelectionArgs) {
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(
() => new Set(),
);
const [bulkActionInFlight, setBulkActionInFlight] =
useState<null | BulkJobAction>(null);
const previousActiveTabRef = useRef<FilterTab>(activeTab);
const selectedJobs = useMemo(
() => activeJobs.filter((job) => selectedJobIds.has(job.id)),
[activeJobs, selectedJobIds],
);
const canSkipSelected = useMemo(
() => canBulkSkip(selectedJobs),
[selectedJobs],
);
const canMoveSelected = useMemo(
() => canBulkMoveToReady(selectedJobs),
[selectedJobs],
);
useEffect(() => {
if (previousActiveTabRef.current === activeTab) return;
previousActiveTabRef.current = activeTab;
setSelectedJobIds(new Set());
}, [activeTab]);
useEffect(() => {
const activeJobIdSet = new Set(activeJobs.map((job) => job.id));
setSelectedJobIds((previous) => {
if (previous.size === 0) return previous;
const next = new Set(
Array.from(previous).filter((jobId) => activeJobIdSet.has(jobId)),
);
return next.size === previous.size ? previous : next;
});
}, [activeJobs]);
const toggleSelectJob = useCallback((jobId: string) => {
setSelectedJobIds((previous) => {
const next = new Set(previous);
if (next.has(jobId)) {
next.delete(jobId);
} else {
next.add(jobId);
}
return next;
});
}, []);
const toggleSelectAll = useCallback(
(checked: boolean) => {
setSelectedJobIds(() => {
if (!checked) return new Set();
const allIds = activeJobs.map((job) => job.id);
if (allIds.length <= MAX_BULK_ACTION_JOB_IDS) {
return new Set(allIds);
}
toast.error(
`Select all is limited to ${MAX_BULK_ACTION_JOB_IDS} jobs per action.`,
);
return new Set(allIds.slice(0, MAX_BULK_ACTION_JOB_IDS));
});
},
[activeJobs],
);
const clearSelection = useCallback(() => {
setSelectedJobIds(new Set());
}, []);
const runBulkAction = useCallback(
async (action: BulkJobAction) => {
const selectedAtStart = Array.from(selectedJobIds);
if (selectedAtStart.length === 0) return;
if (selectedAtStart.length > MAX_BULK_ACTION_JOB_IDS) {
toast.error(
`You can run bulk actions on up to ${MAX_BULK_ACTION_JOB_IDS} jobs at a time.`,
);
return;
}
const selectedAtStartSet = new Set(selectedAtStart);
try {
setBulkActionInFlight(action);
const result = await api.bulkJobAction({
action,
jobIds: selectedAtStart,
});
const failedIds = getFailedJobIds(result);
const successLabel =
action === "skip" ? "jobs skipped" : "jobs moved to Ready";
if (result.failed === 0) {
toast.success(`${result.succeeded} ${successLabel}`);
} else {
toast.error(
`${result.succeeded} succeeded, ${result.failed} failed.`,
);
}
await loadJobs();
setSelectedJobIds((current) => {
const addedDuringRequest = Array.from(current).filter(
(jobId) => !selectedAtStartSet.has(jobId),
);
const removedDuringRequest = Array.from(selectedAtStartSet).filter(
(jobId) => !current.has(jobId),
);
const next = new Set([
...Array.from(failedIds),
...addedDuringRequest,
]);
for (const jobId of removedDuringRequest) next.delete(jobId);
return next;
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to run bulk action";
toast.error(message);
} finally {
setBulkActionInFlight(null);
}
},
[selectedJobIds, loadJobs],
);
return {
selectedJobIds,
canSkipSelected,
canMoveSelected,
bulkActionInFlight,
toggleSelectJob,
toggleSelectAll,
clearSelection,
runBulkAction,
};
}

View File

@ -32,7 +32,7 @@ type Deferred<T> = {
resolve: (value: T) => void; resolve: (value: T) => void;
}; };
const deferred = <T,>(): Deferred<T> => { const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void; let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => { const promise = new Promise<T>((res) => {
resolve = res; resolve = res;
@ -45,7 +45,9 @@ describe("useOrchestratorData", () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers(); vi.useRealTimers();
vi.mocked(api.getJobs).mockResolvedValue(makeResponse("initial") as any); vi.mocked(api.getJobs).mockResolvedValue(makeResponse("initial") as any);
vi.mocked(api.getPipelineStatus).mockResolvedValue({ isRunning: false } as any); vi.mocked(api.getPipelineStatus).mockResolvedValue({
isRunning: false,
} as any);
}); });
it("applies newest loadJobs response when requests resolve out of order", async () => { it("applies newest loadJobs response when requests resolve out of order", async () => {

View File

@ -38,7 +38,10 @@ export const useOrchestratorData = () => {
error instanceof Error ? error.message : "Failed to load jobs"; error instanceof Error ? error.message : "Failed to load jobs";
toast.error(message); toast.error(message);
} finally { } finally {
pendingLoadCountRef.current = Math.max(0, pendingLoadCountRef.current - 1); pendingLoadCountRef.current = Math.max(
0,
pendingLoadCountRef.current - 1,
);
if (pendingLoadCountRef.current === 0) { if (pendingLoadCountRef.current === 0) {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -72,6 +72,115 @@ describe.sequential("Jobs API routes", () => {
expect(deleteBody.data.count).toBe(1); expect(deleteBody.data.count).toBe(1);
}); });
it("runs bulk skip with partial failures", async () => {
const { createJob } = await import("../../repositories/jobs");
const discovered = await createJob({
source: "manual",
title: "Discovered Role",
employer: "Acme",
jobUrl: "https://example.com/job/bulk-discovered",
jobDescription: "Test description",
});
const ready = await createJob({
source: "manual",
title: "Ready Role",
employer: "Beta",
jobUrl: "https://example.com/job/bulk-ready",
jobDescription: "Test description",
});
const applied = await createJob({
source: "manual",
title: "Applied Role",
employer: "Gamma",
jobUrl: "https://example.com/job/bulk-applied",
jobDescription: "Test description",
});
const { updateJob } = await import("../../repositories/jobs");
await updateJob(ready.id, { status: "ready" });
await updateJob(applied.id, { status: "applied" });
const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "skip",
jobIds: [discovered.id, ready.id, applied.id, "missing-id"],
}),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.meta.requestId).toBeTruthy();
expect(body.data.requested).toBe(4);
expect(body.data.succeeded).toBe(2);
expect(body.data.failed).toBe(2);
const failures = body.data.results.filter((r: any) => !r.ok);
expect(failures).toHaveLength(2);
expect(failures.map((r: any) => r.error.code).sort()).toEqual([
"INVALID_REQUEST",
"NOT_FOUND",
]);
});
it("runs bulk move_to_ready and rejects ineligible statuses", async () => {
const { createJob, updateJob } = await import("../../repositories/jobs");
const discovered = await createJob({
source: "manual",
title: "New Role",
employer: "Acme",
jobUrl: "https://example.com/job/bulk-ready-1",
jobDescription: "Test description",
});
const ready = await createJob({
source: "manual",
title: "Already Ready",
employer: "Acme",
jobUrl: "https://example.com/job/bulk-ready-2",
jobDescription: "Test description",
});
await updateJob(ready.id, { status: "ready" });
const { processJob } = await import("../../pipeline/index");
const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move_to_ready",
jobIds: [discovered.id, ready.id],
}),
});
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.data.succeeded).toBe(1);
expect(body.data.failed).toBe(1);
expect(vi.mocked(processJob)).toHaveBeenCalledWith(discovered.id);
expect(
body.data.results.find((r: any) => r.jobId === ready.id).error.code,
).toBe("INVALID_REQUEST");
});
it("validates bulk action payloads", async () => {
const tooManyIds = Array.from(
{ length: 101 },
(_, index) => `job-${index}`,
);
const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "skip",
jobIds: tooManyIds,
}),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("INVALID_REQUEST");
expect(body.meta.requestId).toBeTruthy();
});
it("applies a job and syncs to Notion", async () => { it("applies a job and syncs to Notion", async () => {
const { createNotionEntry } = await import("../../services/notion"); const { createNotionEntry } = await import("../../services/notion");
vi.mocked(createNotionEntry).mockResolvedValue({ vi.mocked(createNotionEntry).mockResolvedValue({

View File

@ -1,10 +1,13 @@
import { okWithMeta } from "@infra/http"; import { fail, ok, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeWebhookPayload } from "@infra/sanitize"; import { sanitizeWebhookPayload } from "@infra/sanitize";
import { import {
APPLICATION_OUTCOMES, APPLICATION_OUTCOMES,
APPLICATION_STAGES, APPLICATION_STAGES,
type ApiResponse, type ApiResponse,
type BulkJobAction,
type BulkJobActionResponse,
type BulkJobActionResult,
type Job, type Job,
type JobStatus, type JobStatus,
type JobsListResponse, type JobsListResponse,
@ -12,6 +15,7 @@ import {
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { isDemoMode, sendDemoBlocked } from "../../config/demo"; import { isDemoMode, sendDemoBlocked } from "../../config/demo";
import { AppError, badRequest } from "../../infra/errors";
import { import {
generateFinalPdf, generateFinalPdf,
processJob, processJob,
@ -136,6 +140,120 @@ const updateOutcomeSchema = z.object({
closedAt: z.number().int().nullable().optional(), closedAt: z.number().int().nullable().optional(),
}); });
const bulkActionRequestSchema = z.object({
action: z.enum(["skip", "move_to_ready"]),
jobIds: z.array(z.string().min(1)).min(1).max(100),
});
const SKIPPABLE_STATUSES: ReadonlySet<JobStatus> = new Set([
"discovered",
"ready",
]);
function mapErrorForResult(error: unknown): {
code: string;
message: string;
details?: unknown;
} {
if (error instanceof AppError) {
return {
code: error.code,
message: error.message,
...(error.details !== undefined ? { details: error.details } : {}),
};
}
if (error instanceof Error) {
return {
code: "INTERNAL_ERROR",
message: error.message || "Unknown error",
};
}
return {
code: "INTERNAL_ERROR",
message: "Unknown error",
};
}
async function executeBulkActionForJob(
action: BulkJobAction,
jobId: string,
): Promise<BulkJobActionResult> {
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) {
throw new AppError({
status: 404,
code: "NOT_FOUND",
message: "Job not found",
});
}
if (action === "skip") {
if (!SKIPPABLE_STATUSES.has(job.status)) {
throw badRequest(`Job is not skippable from status "${job.status}"`, {
jobId,
status: job.status,
allowedStatuses: ["discovered", "ready"],
});
}
const updated = await jobsRepo.updateJob(jobId, { status: "skipped" });
if (!updated) {
throw new AppError({
status: 404,
code: "NOT_FOUND",
message: "Job not found",
});
}
return { jobId, ok: true, job: updated };
}
if (job.status !== "discovered") {
throw badRequest(
`Job is not movable to Ready from status "${job.status}"`,
{
jobId,
status: job.status,
requiredStatus: "discovered",
},
);
}
const processed = await processJob(jobId);
if (!processed.success) {
throw new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: processed.error || "Failed to process job",
});
}
const updated = await jobsRepo.getJobById(jobId);
if (!updated) {
throw new AppError({
status: 404,
code: "NOT_FOUND",
message: "Job not found after processing",
});
}
return { jobId, ok: true, job: updated };
} catch (error) {
const mapped = mapErrorForResult(error);
return {
jobId,
ok: false,
error: {
code: mapped.code,
message: mapped.message,
},
};
}
}
/** /**
* GET /api/jobs - List all jobs * GET /api/jobs - List all jobs
* Query params: status (comma-separated list of statuses to filter) * Query params: status (comma-separated list of statuses to filter)
@ -166,6 +284,62 @@ jobsRouter.get("/", async (req: Request, res: Response) => {
} }
}); });
/**
* POST /api/jobs/bulk-actions - Run a bulk action across selected jobs
*/
jobsRouter.post("/bulk-actions", async (req: Request, res: Response) => {
try {
const parsed = bulkActionRequestSchema.parse(req.body);
const dedupedJobIds = Array.from(new Set(parsed.jobIds));
const results: BulkJobActionResult[] = [];
for (const jobId of dedupedJobIds) {
const result = await executeBulkActionForJob(parsed.action, jobId);
results.push(result);
}
const succeeded = results.filter((result) => result.ok).length;
const failed = results.length - succeeded;
const payload: BulkJobActionResponse = {
action: parsed.action,
requested: dedupedJobIds.length,
succeeded,
failed,
results,
};
logger.info("Bulk job action completed", {
route: "POST /api/jobs/bulk-actions",
action: parsed.action,
requested: dedupedJobIds.length,
succeeded,
failed,
});
ok(res, payload);
} catch (error) {
const err =
error instanceof z.ZodError
? badRequest("Invalid bulk action request", error.flatten())
: error instanceof AppError
? error
: new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "Unknown error",
});
logger.error("Bulk job action failed", {
route: "POST /api/jobs/bulk-actions",
status: err.status,
code: err.code,
details: err.details,
});
fail(res, err);
}
});
/** /**
* GET /api/jobs/:id - Get a single job * GET /api/jobs/:id - Get a single job
*/ */

View File

@ -339,6 +339,36 @@ export interface JobsListResponse {
byStatus: Record<JobStatus, number>; byStatus: Record<JobStatus, number>;
} }
export type BulkJobAction = "skip" | "move_to_ready";
export interface BulkJobActionRequest {
action: BulkJobAction;
jobIds: string[];
}
export type BulkJobActionResult =
| {
jobId: string;
ok: true;
job: Job;
}
| {
jobId: string;
ok: false;
error: {
code: string;
message: string;
};
};
export interface BulkJobActionResponse {
action: BulkJobAction;
requested: number;
succeeded: number;
failed: number;
results: BulkJobActionResult[];
}
export interface UkVisaJobsSearchResponse { export interface UkVisaJobsSearchResponse {
jobs: CreateJobInput[]; jobs: CreateJobInput[];
totalJobs: number; totalJobs: number;