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:
parent
855ac0c5a5
commit
cfabee5f45
@ -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()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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..."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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"));
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
112
orchestrator/src/client/pages/orchestrator/bulkActions.test.ts
Normal file
112
orchestrator/src/client/pages/orchestrator/bulkActions.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
orchestrator/src/client/pages/orchestrator/bulkActions.ts
Normal file
20
orchestrator/src/client/pages/orchestrator/bulkActions.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user