modal to configure pipeline settings on pipeline runs (#99)
* feat(orchestrator): add unified run modal shell with Automatic/Manual tabs * feat(orchestrator): implement Automatic tab presets, estimate, and save+run flow * refactor(manual-import): reuse manual import flow inside unified run modal * refactor(settings): move pipeline tuning out of settings page into run modal * stage 5 * jobs per term simplified * copy improvement * pill input * better UI * style(orchestrator): align run settings inputs on one row * style(orchestrator): remove hover and pointer affordance from term pills * style(orchestrator): restore hover and pointer affordance for term pills * style(orchestrator): make search term pill hover more prominent * better hover * refactor(orchestrator): use react-hook-form in automatic run panel * formatting * fix(orchestrator): resolve biome issues in automatic run modal * better copy * feat(orchestrator): auto-select custom preset on manual config changes * remove badge * feat(orchestrator): redesign automatic run panel with collapsible advanced settings * refactor(orchestrator): move estimate summary to footer and dedupe sources * style(orchestrator): separate search term input from term pills * style(orchestrator): remove save preset action from automatic footer * ux(orchestrator): make entire search term pill tap-to-remove * remove badge * remove badge * fix(orchestrator): return zero estimate when search terms are empty
This commit is contained in:
parent
c1605065fd
commit
60788b0f6a
563
orchestrator/src/client/components/ManualImportFlow.tsx
Normal file
563
orchestrator/src/client/components/ManualImportFlow.tsx
Normal file
@ -0,0 +1,563 @@
|
||||
import * as api from "@client/api";
|
||||
import type { ManualJobDraft } from "@shared/types.js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ClipboardPaste,
|
||||
Link,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type ManualImportStep = "paste" | "loading" | "review";
|
||||
|
||||
type ManualJobDraftState = {
|
||||
title: string;
|
||||
employer: string;
|
||||
jobUrl: string;
|
||||
applicationLink: string;
|
||||
location: string;
|
||||
salary: string;
|
||||
deadline: string;
|
||||
jobDescription: string;
|
||||
jobType: string;
|
||||
jobLevel: string;
|
||||
jobFunction: string;
|
||||
disciplines: string;
|
||||
degreeRequired: string;
|
||||
starting: string;
|
||||
};
|
||||
|
||||
const emptyDraft: ManualJobDraftState = {
|
||||
title: "",
|
||||
employer: "",
|
||||
jobUrl: "",
|
||||
applicationLink: "",
|
||||
location: "",
|
||||
salary: "",
|
||||
deadline: "",
|
||||
jobDescription: "",
|
||||
jobType: "",
|
||||
jobLevel: "",
|
||||
jobFunction: "",
|
||||
disciplines: "",
|
||||
degreeRequired: "",
|
||||
starting: "",
|
||||
};
|
||||
|
||||
const normalizeDraft = (
|
||||
draft?: ManualJobDraft | null,
|
||||
jd?: string,
|
||||
): ManualJobDraftState => ({
|
||||
...emptyDraft,
|
||||
title: draft?.title ?? "",
|
||||
employer: draft?.employer ?? "",
|
||||
jobUrl: draft?.jobUrl ?? "",
|
||||
applicationLink: draft?.applicationLink ?? "",
|
||||
location: draft?.location ?? "",
|
||||
salary: draft?.salary ?? "",
|
||||
deadline: draft?.deadline ?? "",
|
||||
jobDescription: jd ?? draft?.jobDescription ?? "",
|
||||
jobType: draft?.jobType ?? "",
|
||||
jobLevel: draft?.jobLevel ?? "",
|
||||
jobFunction: draft?.jobFunction ?? "",
|
||||
disciplines: draft?.disciplines ?? "",
|
||||
degreeRequired: draft?.degreeRequired ?? "",
|
||||
starting: draft?.starting ?? "",
|
||||
});
|
||||
|
||||
const toPayload = (draft: ManualJobDraftState): ManualJobDraft => {
|
||||
const clean = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
title: clean(draft.title),
|
||||
employer: clean(draft.employer),
|
||||
jobUrl: clean(draft.jobUrl),
|
||||
applicationLink: clean(draft.applicationLink),
|
||||
location: clean(draft.location),
|
||||
salary: clean(draft.salary),
|
||||
deadline: clean(draft.deadline),
|
||||
jobDescription: clean(draft.jobDescription),
|
||||
jobType: clean(draft.jobType),
|
||||
jobLevel: clean(draft.jobLevel),
|
||||
jobFunction: clean(draft.jobFunction),
|
||||
disciplines: clean(draft.disciplines),
|
||||
degreeRequired: clean(draft.degreeRequired),
|
||||
starting: clean(draft.starting),
|
||||
};
|
||||
};
|
||||
|
||||
interface ManualImportFlowProps {
|
||||
active: boolean;
|
||||
onImported: (jobId: string) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ManualImportFlow: React.FC<ManualImportFlowProps> = ({
|
||||
active,
|
||||
onImported,
|
||||
onClose,
|
||||
}) => {
|
||||
const [step, setStep] = useState<ManualImportStep>("paste");
|
||||
const [rawDescription, setRawDescription] = useState("");
|
||||
const [fetchUrl, setFetchUrl] = useState("");
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [draft, setDraft] = useState<ManualJobDraftState>(emptyDraft);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
setStep("paste");
|
||||
setRawDescription("");
|
||||
setFetchUrl("");
|
||||
setIsFetching(false);
|
||||
setDraft(emptyDraft);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setIsImporting(false);
|
||||
}, [active]);
|
||||
|
||||
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
|
||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
||||
|
||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||
const canFetch =
|
||||
fetchUrl.trim().length > 0 && !isFetching && step === "paste";
|
||||
const canImport = useMemo(() => {
|
||||
if (step !== "review") return false;
|
||||
return (
|
||||
draft.title.trim().length > 0 &&
|
||||
draft.employer.trim().length > 0 &&
|
||||
draft.jobDescription.trim().length > 0
|
||||
);
|
||||
}, [draft, step]);
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!fetchUrl.trim()) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setIsFetching(true);
|
||||
|
||||
const fetchResponse = await api.fetchJobFromUrl({ url: fetchUrl.trim() });
|
||||
const fetchedContent = fetchResponse.content;
|
||||
const fetchedUrl = fetchResponse.url;
|
||||
|
||||
setIsFetching(false);
|
||||
setStep("loading");
|
||||
const inferResponse = await api.inferManualJob({
|
||||
jobDescription: fetchedContent,
|
||||
});
|
||||
const normalized = normalizeDraft(inferResponse.job);
|
||||
|
||||
if (!normalized.jobUrl) {
|
||||
normalized.jobUrl = fetchedUrl;
|
||||
}
|
||||
|
||||
setDraft(normalized);
|
||||
setWarning(inferResponse.warning ?? null);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to fetch URL";
|
||||
setError(message);
|
||||
setIsFetching(false);
|
||||
setStep("paste");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!rawDescription.trim()) {
|
||||
setError("Paste a job description to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setStep("loading");
|
||||
const response = await api.inferManualJob({
|
||||
jobDescription: rawDescription,
|
||||
});
|
||||
const normalized = normalizeDraft(response.job, rawDescription.trim());
|
||||
if (draft.jobUrl && !normalized.jobUrl) {
|
||||
normalized.jobUrl = draft.jobUrl;
|
||||
}
|
||||
setDraft(normalized);
|
||||
setWarning(response.warning ?? null);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to analyze job description";
|
||||
setError(message);
|
||||
setStep("paste");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!canImport) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
const payload = toPayload(draft);
|
||||
const created = await api.importManualJob({ job: payload });
|
||||
toast.success("Job imported", {
|
||||
description: "The job is now in the discovered column.",
|
||||
});
|
||||
await onImported(created.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to import job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Step {stepIndex + 1} of 3</span>
|
||||
<span>{stepLabel}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-muted/40">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary/60 transition-all"
|
||||
style={{ width: `${((stepIndex + 1) / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-1 overflow-y-auto pr-1">
|
||||
{step === "paste" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="fetch-url"
|
||||
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Job URL (optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="fetch-url"
|
||||
value={fetchUrl}
|
||||
onChange={(event) => setFetchUrl(event.target.value)}
|
||||
placeholder="https://example.com/job-posting"
|
||||
className="flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && canFetch) {
|
||||
event.preventDefault();
|
||||
void handleFetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isFetching}
|
||||
className="gap-2 shrink-0"
|
||||
onClick={async () => {
|
||||
if (fetchUrl.trim()) {
|
||||
await handleFetch();
|
||||
} else {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) setFetchUrl(text.trim());
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : fetchUrl.trim() ? (
|
||||
<Link className="h-4 w-4" />
|
||||
) : (
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
)}
|
||||
{isFetching
|
||||
? "Fetching..."
|
||||
: fetchUrl.trim()
|
||||
? "Fetch"
|
||||
: "Paste"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="raw-description"
|
||||
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Job description
|
||||
</label>
|
||||
<Textarea
|
||||
id="raw-description"
|
||||
value={rawDescription}
|
||||
onChange={(event) => setRawDescription(event.target.value)}
|
||||
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
|
||||
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={
|
||||
fetchUrl.trim()
|
||||
? () => void handleFetch()
|
||||
: () => void handleAnalyze()
|
||||
}
|
||||
disabled={isFetching || (!canFetch && !canAnalyze)}
|
||||
className="w-full h-10 gap-2"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{isFetching ? "Fetching..." : "Analyze JD"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "loading" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-semibold">
|
||||
Analyzing job description
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Extracting title, company, location, and other details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4 pb-4">
|
||||
{warning && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep("paste")}
|
||||
className="gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Edit JD
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Required: title, employer, description
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FieldInput
|
||||
id="draft-title"
|
||||
label="Title *"
|
||||
value={draft.title}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, title: value }))
|
||||
}
|
||||
placeholder="e.g. Junior Backend Engineer"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-employer"
|
||||
label="Employer *"
|
||||
value={draft.employer}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, employer: value }))
|
||||
}
|
||||
placeholder="e.g. Acme Labs"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-location"
|
||||
label="Location"
|
||||
value={draft.location}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, location: value }))
|
||||
}
|
||||
placeholder="e.g. London, UK"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-salary"
|
||||
label="Salary"
|
||||
value={draft.salary}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, salary: value }))
|
||||
}
|
||||
placeholder="e.g. GBP 45k-55k"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-deadline"
|
||||
label="Deadline"
|
||||
value={draft.deadline}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, deadline: value }))
|
||||
}
|
||||
placeholder="e.g. 30 Sep 2025"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-jobType"
|
||||
label="Job type"
|
||||
value={draft.jobType}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, jobType: value }))
|
||||
}
|
||||
placeholder="e.g. Full-time"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-jobLevel"
|
||||
label="Job level"
|
||||
value={draft.jobLevel}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, jobLevel: value }))
|
||||
}
|
||||
placeholder="e.g. Graduate"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-jobFunction"
|
||||
label="Job function"
|
||||
value={draft.jobFunction}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, jobFunction: value }))
|
||||
}
|
||||
placeholder="e.g. Software Engineering"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-disciplines"
|
||||
label="Disciplines"
|
||||
value={draft.disciplines}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, disciplines: value }))
|
||||
}
|
||||
placeholder="e.g. Computer Science"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-degreeRequired"
|
||||
label="Degree required"
|
||||
value={draft.degreeRequired}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, degreeRequired: value }))
|
||||
}
|
||||
placeholder="e.g. BSc or MSc"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-starting"
|
||||
label="Starting"
|
||||
value={draft.starting}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, starting: value }))
|
||||
}
|
||||
placeholder="e.g. September 2026"
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-jobUrl"
|
||||
label="Job URL"
|
||||
value={draft.jobUrl}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, jobUrl: value }))
|
||||
}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<FieldInput
|
||||
id="draft-applicationLink"
|
||||
label="Application URL"
|
||||
value={draft.applicationLink}
|
||||
onChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, applicationLink: value }))
|
||||
}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-jobDescription"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Job description *
|
||||
</label>
|
||||
<Textarea
|
||||
id="draft-jobDescription"
|
||||
value={draft.jobDescription}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
jobDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Paste the job description..."
|
||||
className="min-h-[180px] font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void handleImport()}
|
||||
disabled={!canImport || isImporting}
|
||||
className="w-full h-10 gap-2"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{isImporting ? "Importing..." : "Import job"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldInput: React.FC<{
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}> = ({ id, label, value, onChange, placeholder }) => (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -2,21 +2,8 @@
|
||||
* Manual job import flow (paste JD -> infer -> review -> import).
|
||||
*/
|
||||
|
||||
import type { ManualJobDraft } from "@shared/types.js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ClipboardPaste,
|
||||
FileText,
|
||||
Link,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -24,90 +11,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
|
||||
type ManualImportStep = "paste" | "loading" | "review";
|
||||
|
||||
type ManualJobDraftState = {
|
||||
title: string;
|
||||
employer: string;
|
||||
jobUrl: string;
|
||||
applicationLink: string;
|
||||
location: string;
|
||||
salary: string;
|
||||
deadline: string;
|
||||
jobDescription: string;
|
||||
jobType: string;
|
||||
jobLevel: string;
|
||||
jobFunction: string;
|
||||
disciplines: string;
|
||||
degreeRequired: string;
|
||||
starting: string;
|
||||
};
|
||||
|
||||
const emptyDraft: ManualJobDraftState = {
|
||||
title: "",
|
||||
employer: "",
|
||||
jobUrl: "",
|
||||
applicationLink: "",
|
||||
location: "",
|
||||
salary: "",
|
||||
deadline: "",
|
||||
jobDescription: "",
|
||||
jobType: "",
|
||||
jobLevel: "",
|
||||
jobFunction: "",
|
||||
disciplines: "",
|
||||
degreeRequired: "",
|
||||
starting: "",
|
||||
};
|
||||
|
||||
const normalizeDraft = (
|
||||
draft?: ManualJobDraft | null,
|
||||
jd?: string,
|
||||
): ManualJobDraftState => ({
|
||||
...emptyDraft,
|
||||
title: draft?.title ?? "",
|
||||
employer: draft?.employer ?? "",
|
||||
jobUrl: draft?.jobUrl ?? "",
|
||||
applicationLink: draft?.applicationLink ?? "",
|
||||
location: draft?.location ?? "",
|
||||
salary: draft?.salary ?? "",
|
||||
deadline: draft?.deadline ?? "",
|
||||
jobDescription: jd ?? draft?.jobDescription ?? "",
|
||||
jobType: draft?.jobType ?? "",
|
||||
jobLevel: draft?.jobLevel ?? "",
|
||||
jobFunction: draft?.jobFunction ?? "",
|
||||
disciplines: draft?.disciplines ?? "",
|
||||
degreeRequired: draft?.degreeRequired ?? "",
|
||||
starting: draft?.starting ?? "",
|
||||
});
|
||||
|
||||
const toPayload = (draft: ManualJobDraftState): ManualJobDraft => {
|
||||
const clean = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
title: clean(draft.title),
|
||||
employer: clean(draft.employer),
|
||||
jobUrl: clean(draft.jobUrl),
|
||||
applicationLink: clean(draft.applicationLink),
|
||||
location: clean(draft.location),
|
||||
salary: clean(draft.salary),
|
||||
deadline: clean(draft.deadline),
|
||||
jobDescription: clean(draft.jobDescription),
|
||||
jobType: clean(draft.jobType),
|
||||
jobLevel: clean(draft.jobLevel),
|
||||
jobFunction: clean(draft.jobFunction),
|
||||
disciplines: clean(draft.disciplines),
|
||||
degreeRequired: clean(draft.degreeRequired),
|
||||
starting: clean(draft.starting),
|
||||
};
|
||||
};
|
||||
import { ManualImportFlow } from "./ManualImportFlow";
|
||||
|
||||
interface ManualImportSheetProps {
|
||||
open: boolean;
|
||||
@ -120,135 +24,6 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}) => {
|
||||
const [step, setStep] = useState<ManualImportStep>("paste");
|
||||
const [rawDescription, setRawDescription] = useState("");
|
||||
const [fetchUrl, setFetchUrl] = useState("");
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [draft, setDraft] = useState<ManualJobDraftState>(emptyDraft);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setStep("paste");
|
||||
setRawDescription("");
|
||||
setFetchUrl("");
|
||||
setIsFetching(false);
|
||||
setDraft(emptyDraft);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
|
||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
||||
|
||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||
const canFetch =
|
||||
fetchUrl.trim().length > 0 && !isFetching && step === "paste";
|
||||
const canImport = useMemo(() => {
|
||||
if (step !== "review") return false;
|
||||
return (
|
||||
draft.title.trim().length > 0 &&
|
||||
draft.employer.trim().length > 0 &&
|
||||
draft.jobDescription.trim().length > 0
|
||||
);
|
||||
}, [draft, step]);
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!fetchUrl.trim()) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setIsFetching(true);
|
||||
|
||||
// Fetch the URL content
|
||||
const fetchResponse = await api.fetchJobFromUrl({ url: fetchUrl.trim() });
|
||||
const fetchedContent = fetchResponse.content;
|
||||
const fetchedUrl = fetchResponse.url;
|
||||
|
||||
setIsFetching(false);
|
||||
|
||||
// Automatically proceed to analysis
|
||||
setStep("loading");
|
||||
const inferResponse = await api.inferManualJob({
|
||||
jobDescription: fetchedContent,
|
||||
});
|
||||
// Don't pass raw HTML as job description - let user fill it in or use inferred data
|
||||
const normalized = normalizeDraft(inferResponse.job);
|
||||
|
||||
// Preserve the fetched URL
|
||||
if (!normalized.jobUrl) {
|
||||
normalized.jobUrl = fetchedUrl;
|
||||
}
|
||||
|
||||
setDraft(normalized);
|
||||
setWarning(inferResponse.warning ?? null);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to fetch URL";
|
||||
setError(message);
|
||||
setIsFetching(false);
|
||||
setStep("paste");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!rawDescription.trim()) {
|
||||
setError("Paste a job description to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setStep("loading");
|
||||
const response = await api.inferManualJob({
|
||||
jobDescription: rawDescription,
|
||||
});
|
||||
const normalized = normalizeDraft(response.job, rawDescription.trim());
|
||||
// Preserve the fetched URL if we fetched from a URL
|
||||
if (draft.jobUrl && !normalized.jobUrl) {
|
||||
normalized.jobUrl = draft.jobUrl;
|
||||
}
|
||||
setDraft(normalized);
|
||||
setWarning(response.warning ?? null);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to analyze job description";
|
||||
setError(message);
|
||||
setStep("paste");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!canImport) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
const payload = toPayload(draft);
|
||||
const created = await api.importManualJob({ job: payload });
|
||||
toast.success("Job imported", {
|
||||
description: "The job is now in the discovered column.",
|
||||
});
|
||||
await onImported(created.id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to import job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-xl overflow-hidden">
|
||||
@ -266,444 +41,12 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Step {stepIndex + 1} of 3</span>
|
||||
<span>{stepLabel}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-muted/40">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary/60 transition-all"
|
||||
style={{ width: `${((stepIndex + 1) / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-1 overflow-y-auto pr-1">
|
||||
{step === "paste" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="fetch-url"
|
||||
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Job URL (optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="fetch-url"
|
||||
value={fetchUrl}
|
||||
onChange={(event) => setFetchUrl(event.target.value)}
|
||||
placeholder="https://example.com/job-posting"
|
||||
className="flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && canFetch) {
|
||||
event.preventDefault();
|
||||
handleFetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isFetching}
|
||||
className="gap-2 shrink-0"
|
||||
onClick={async () => {
|
||||
if (fetchUrl.trim()) {
|
||||
handleFetch();
|
||||
} else {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) setFetchUrl(text.trim());
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : fetchUrl.trim() ? (
|
||||
<Link className="h-4 w-4" />
|
||||
) : (
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
)}
|
||||
{isFetching
|
||||
? "Fetching..."
|
||||
: fetchUrl.trim()
|
||||
? "Fetch"
|
||||
: "Paste"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="raw-description"
|
||||
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Job description
|
||||
</label>
|
||||
<Textarea
|
||||
id="raw-description"
|
||||
value={rawDescription}
|
||||
onChange={(event) => setRawDescription(event.target.value)}
|
||||
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
|
||||
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={fetchUrl.trim() ? handleFetch : handleAnalyze}
|
||||
disabled={isFetching || (!canFetch && !canAnalyze)}
|
||||
className="w-full h-10 gap-2"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{isFetching ? "Fetching..." : "Analyze JD"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "loading" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-semibold">
|
||||
Analyzing job description
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Extracting title, company, location, and other details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4 pb-4">
|
||||
{warning && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep("paste")}
|
||||
className="gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Edit JD
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Required: title, employer, description
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-title"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Title *
|
||||
</label>
|
||||
<Input
|
||||
id="draft-title"
|
||||
value={draft.title}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Junior Backend Engineer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-employer"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Employer *
|
||||
</label>
|
||||
<Input
|
||||
id="draft-employer"
|
||||
value={draft.employer}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
employer: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Acme Labs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-location"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Location
|
||||
</label>
|
||||
<Input
|
||||
id="draft-location"
|
||||
value={draft.location}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
location: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. London, UK"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-salary"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Salary
|
||||
</label>
|
||||
<Input
|
||||
id="draft-salary"
|
||||
value={draft.salary}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
salary: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. GBP 45k-55k"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-deadline"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Deadline
|
||||
</label>
|
||||
<Input
|
||||
id="draft-deadline"
|
||||
value={draft.deadline}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
deadline: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. 30 Sep 2025"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-jobType"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Job type
|
||||
</label>
|
||||
<Input
|
||||
id="draft-jobType"
|
||||
value={draft.jobType}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
jobType: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Full-time"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-jobLevel"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Job level
|
||||
</label>
|
||||
<Input
|
||||
id="draft-jobLevel"
|
||||
value={draft.jobLevel}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
jobLevel: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Graduate"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-jobFunction"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Job function
|
||||
</label>
|
||||
<Input
|
||||
id="draft-jobFunction"
|
||||
value={draft.jobFunction}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
jobFunction: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Software Engineering"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-disciplines"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Disciplines
|
||||
</label>
|
||||
<Input
|
||||
id="draft-disciplines"
|
||||
value={draft.disciplines}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
disciplines: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Computer Science"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-degreeRequired"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Degree required
|
||||
</label>
|
||||
<Input
|
||||
id="draft-degreeRequired"
|
||||
value={draft.degreeRequired}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
degreeRequired: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. BSc or MSc"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-starting"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Starting
|
||||
</label>
|
||||
<Input
|
||||
id="draft-starting"
|
||||
value={draft.starting}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
starting: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Summer 2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-jobUrl"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Job URL
|
||||
</label>
|
||||
<Input
|
||||
id="draft-jobUrl"
|
||||
value={draft.jobUrl}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
jobUrl: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="draft-applicationLink"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Application link
|
||||
</label>
|
||||
<Input
|
||||
id="draft-applicationLink"
|
||||
value={draft.applicationLink}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
applicationLink: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="draft-jobDescription"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Job description *
|
||||
</label>
|
||||
<Textarea
|
||||
id="draft-jobDescription"
|
||||
value={draft.jobDescription}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
jobDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
||||
placeholder="Paste the job description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Separator />
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!canImport || isImporting}
|
||||
className={cn(
|
||||
"w-full h-10 gap-2",
|
||||
!canImport && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
{isImporting ? "Importing..." : "Import job"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 min-h-0 flex-1">
|
||||
<ManualImportFlow
|
||||
active={open}
|
||||
onImported={onImported}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@ -4,6 +4,14 @@ const GITHUB_REPO = "DaKheera47/job-ops";
|
||||
const STORAGE_KEY = "jobops_version_check";
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
return (
|
||||
typeof localStorage !== "undefined" &&
|
||||
typeof localStorage.getItem === "function" &&
|
||||
typeof localStorage.setItem === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export interface VersionCheckResult {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
@ -44,7 +52,7 @@ export async function checkForUpdate(): Promise<VersionCheckResult> {
|
||||
const currentVersion = parseVersion(currentRaw);
|
||||
|
||||
// Check cached result
|
||||
const cached = localStorage.getItem(STORAGE_KEY);
|
||||
const cached = canUseStorage() ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed: VersionCheckResult = JSON.parse(cached);
|
||||
@ -85,7 +93,9 @@ export async function checkForUpdate(): Promise<VersionCheckResult> {
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(result));
|
||||
if (canUseStorage()) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(result));
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
// On error, return current version with no update info
|
||||
|
||||
@ -2,9 +2,20 @@ import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { OrchestratorPage } from "./OrchestratorPage";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
runPipeline: vi.fn().mockResolvedValue({ message: "ok" }),
|
||||
getPipelineStatus: vi.fn().mockResolvedValue({
|
||||
isRunning: false,
|
||||
lastRun: null,
|
||||
nextScheduledRun: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const jobFixture: Job = {
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
@ -113,6 +124,7 @@ vi.mock("../hooks/useSettings", () => ({
|
||||
ukvisajobsEmail: null,
|
||||
ukvisajobsPasswordHint: null,
|
||||
},
|
||||
refreshSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -186,6 +198,34 @@ vi.mock("./orchestrator/JobListPanel", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/RunModeModal", () => ({
|
||||
RunModeModal: ({
|
||||
onSaveAndRunAutomatic,
|
||||
}: {
|
||||
onSaveAndRunAutomatic: (values: {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
}) => Promise<void>;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="run-automatic"
|
||||
onClick={() =>
|
||||
void onSaveAndRunAutomatic({
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
})
|
||||
}
|
||||
>
|
||||
Run automatic
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../components", () => ({
|
||||
ManualImportSheet: () => <div data-testid="manual-import" />,
|
||||
}));
|
||||
@ -350,4 +390,35 @@ describe("OrchestratorPage", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves automatic settings from modal", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
const setIntervalSpy = vi
|
||||
.spyOn(globalThis, "setInterval")
|
||||
.mockReturnValue(0 as unknown as NodeJS.Timeout);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith({
|
||||
searchTerms: ["backend"],
|
||||
jobspyResultsWanted: 150,
|
||||
gradcrackerMaxJobsPerTerm: 150,
|
||||
ukvisajobsMaxJobs: 150,
|
||||
});
|
||||
});
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,7 +11,8 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import * as api from "../api";
|
||||
import { ManualImportSheet } from "../components";
|
||||
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
|
||||
import type { FilterTab, JobSort } from "./orchestrator/constants";
|
||||
import { DEFAULT_SORT } from "./orchestrator/constants";
|
||||
import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar";
|
||||
@ -20,6 +21,8 @@ import { JobListPanel } from "./orchestrator/JobListPanel";
|
||||
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
|
||||
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
|
||||
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
|
||||
import { RunModeModal } from "./orchestrator/RunModeModal";
|
||||
import type { RunMode } from "./orchestrator/run-mode";
|
||||
import { useBulkJobSelection } from "./orchestrator/useBulkJobSelection";
|
||||
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
|
||||
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
|
||||
@ -131,7 +134,8 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}, [tab, navigateWithContext]);
|
||||
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
|
||||
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
|
||||
const [runMode, setRunMode] = useState<RunMode>("automatic");
|
||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
@ -153,7 +157,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
[navigateWithContext, activeTab],
|
||||
);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { settings, refreshSettings } = useSettings();
|
||||
const {
|
||||
jobs,
|
||||
stats,
|
||||
@ -167,8 +171,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
() => getEnabledSources(settings ?? null),
|
||||
[settings],
|
||||
);
|
||||
const { pipelineSources, setPipelineSources, toggleSource } =
|
||||
usePipelineSources(enabledSources);
|
||||
const { pipelineSources, toggleSource } = usePipelineSources(enabledSources);
|
||||
|
||||
const activeJobs = useFilteredJobs(
|
||||
jobs,
|
||||
@ -208,43 +211,78 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
|
||||
|
||||
const openRunMode = useCallback((mode: RunMode) => {
|
||||
setRunMode(mode);
|
||||
setIsRunModeModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleManualImported = useCallback(
|
||||
async (importedJobId: string) => {
|
||||
// Refresh jobs and navigate to the new job in discovered tab
|
||||
await loadJobs();
|
||||
navigateWithContext("discovered", importedJobId);
|
||||
},
|
||||
[loadJobs, navigateWithContext],
|
||||
);
|
||||
|
||||
const handleRunPipeline = async () => {
|
||||
try {
|
||||
setIsPipelineRunning(true);
|
||||
await api.runPipeline({ sources: pipelineSources });
|
||||
toast.message("Pipeline started", {
|
||||
description: `Sources: ${pipelineSources.join(", ")}. This may take a few minutes.`,
|
||||
});
|
||||
const startPipelineRun = useCallback(
|
||||
async (config: {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
sources: JobSource[];
|
||||
}) => {
|
||||
try {
|
||||
setIsPipelineRunning(true);
|
||||
await api.runPipeline(config);
|
||||
toast.message("Pipeline started", {
|
||||
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
|
||||
});
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await api.getPipelineStatus();
|
||||
if (!status.isRunning) {
|
||||
clearInterval(pollInterval);
|
||||
setIsPipelineRunning(false);
|
||||
await loadJobs();
|
||||
toast.success("Pipeline completed");
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await api.getPipelineStatus();
|
||||
if (!status.isRunning) {
|
||||
clearInterval(pollInterval);
|
||||
setIsPipelineRunning(false);
|
||||
await loadJobs();
|
||||
toast.success("Pipeline completed");
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
setIsPipelineRunning(false);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to start pipeline";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
setIsPipelineRunning(false);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to start pipeline";
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[loadJobs, setIsPipelineRunning],
|
||||
);
|
||||
|
||||
const handleSaveAndRunAutomatic = useCallback(
|
||||
async (values: AutomaticRunValues) => {
|
||||
const limits = deriveExtractorLimits({
|
||||
budget: values.runBudget,
|
||||
searchTerms: values.searchTerms,
|
||||
sources: pipelineSources,
|
||||
});
|
||||
await api.updateSettings({
|
||||
searchTerms: values.searchTerms,
|
||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
||||
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||
});
|
||||
await refreshSettings();
|
||||
await startPipelineRun({
|
||||
topN: values.topN,
|
||||
minSuitabilityScore: values.minSuitabilityScore,
|
||||
sources: pipelineSources,
|
||||
});
|
||||
setIsRunModeModalOpen(false);
|
||||
},
|
||||
[pipelineSources, refreshSettings, startPipelineRun],
|
||||
);
|
||||
|
||||
const handleSelectJob = (id: string) => {
|
||||
handleSelectJobId(id);
|
||||
@ -315,11 +353,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onNavOpenChange={setNavOpen}
|
||||
isPipelineRunning={isPipelineRunning}
|
||||
pipelineSources={pipelineSources}
|
||||
enabledSources={enabledSources}
|
||||
onToggleSource={toggleSource}
|
||||
onSetPipelineSources={setPipelineSources}
|
||||
onRunPipeline={handleRunPipeline}
|
||||
onOpenManualImport={() => setIsManualImportOpen(true)}
|
||||
onOpenAutomaticRun={() => openRunMode("automatic")}
|
||||
/>
|
||||
|
||||
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
||||
@ -386,10 +420,18 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
<ManualImportSheet
|
||||
open={isManualImportOpen}
|
||||
onOpenChange={setIsManualImportOpen}
|
||||
onImported={handleManualImported}
|
||||
<RunModeModal
|
||||
open={isRunModeModalOpen}
|
||||
mode={runMode}
|
||||
settings={settings ?? null}
|
||||
enabledSources={enabledSources}
|
||||
pipelineSources={pipelineSources}
|
||||
onToggleSource={toggleSource}
|
||||
isPipelineRunning={isPipelineRunning}
|
||||
onOpenChange={setIsRunModeModalOpen}
|
||||
onModeChange={setRunMode}
|
||||
onSaveAndRunAutomatic={handleSaveAndRunAutomatic}
|
||||
onManualImported={handleManualImported}
|
||||
/>
|
||||
|
||||
{!isDesktop && (
|
||||
|
||||
@ -252,18 +252,23 @@ describe("SettingsPage", () => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("enables save button when numeric setting is changed", async () => {
|
||||
it("hides pipeline tuning sections that moved to run modal", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
renderPage();
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
|
||||
const visaTrigger = await screen.findByRole("button", {
|
||||
name: /ukvisajobs extractor/i,
|
||||
});
|
||||
fireEvent.click(visaTrigger);
|
||||
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i);
|
||||
fireEvent.change(maxJobsInput, { target: { value: "100" } });
|
||||
expect(saveButton).toBeEnabled();
|
||||
await screen.findByRole("button", { name: /model/i });
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /ukvisajobs extractor/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /gradcracker extractor/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /search terms/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /jobspy scraper/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables save button when display setting is changed", async () => {
|
||||
|
||||
@ -4,13 +4,9 @@ import { BackupSettingsSection } from "@client/pages/settings/components/BackupS
|
||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
||||
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection";
|
||||
import { JobspySection } from "@client/pages/settings/components/JobspySection";
|
||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
||||
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
||||
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
||||
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
|
||||
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
|
||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
||||
import {
|
||||
type LlmProviderId,
|
||||
@ -867,26 +863,6 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<UkvisajobsSection
|
||||
values={ukvisajobs}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<GradcrackerSection
|
||||
values={gradcracker}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SearchTermsSection
|
||||
values={searchTerms}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobspySection
|
||||
values={jobspy}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ReactiveResumeSection
|
||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||
setRxResumeBaseResumeIdDraft={(value) => {
|
||||
|
||||
421
orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
Normal file
421
orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import type { AppSettings, JobSource } from "@shared/types";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import {
|
||||
AUTOMATIC_PRESETS,
|
||||
type AutomaticPresetId,
|
||||
type AutomaticRunValues,
|
||||
calculateAutomaticEstimate,
|
||||
loadAutomaticRunMemory,
|
||||
parseSearchTermsInput,
|
||||
saveAutomaticRunMemory,
|
||||
} from "./automatic-run";
|
||||
|
||||
interface AutomaticRunTabProps {
|
||||
open: boolean;
|
||||
settings: AppSettings | null;
|
||||
enabledSources: JobSource[];
|
||||
pipelineSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
isPipelineRunning: boolean;
|
||||
onSaveAndRun: (values: AutomaticRunValues) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: AutomaticRunValues = {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
searchTerms: ["web developer"],
|
||||
runBudget: 200,
|
||||
};
|
||||
|
||||
interface AutomaticRunFormValues {
|
||||
topN: string;
|
||||
minSuitabilityScore: string;
|
||||
runBudget: string;
|
||||
searchTerms: string[];
|
||||
searchTermDraft: string;
|
||||
}
|
||||
|
||||
type AutomaticPresetSelection = AutomaticPresetId | "custom";
|
||||
|
||||
function toNumber(input: string, min: number, max: number, fallback: number) {
|
||||
const parsed = Number.parseInt(input, 10);
|
||||
if (Number.isNaN(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function getPresetSelection(values: {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
runBudget: number;
|
||||
}): AutomaticPresetSelection {
|
||||
if (
|
||||
values.topN === AUTOMATIC_PRESETS.fast.topN &&
|
||||
values.minSuitabilityScore === AUTOMATIC_PRESETS.fast.minSuitabilityScore &&
|
||||
values.runBudget === AUTOMATIC_PRESETS.fast.runBudget
|
||||
) {
|
||||
return "fast";
|
||||
}
|
||||
if (
|
||||
values.topN === AUTOMATIC_PRESETS.balanced.topN &&
|
||||
values.minSuitabilityScore ===
|
||||
AUTOMATIC_PRESETS.balanced.minSuitabilityScore &&
|
||||
values.runBudget === AUTOMATIC_PRESETS.balanced.runBudget
|
||||
) {
|
||||
return "balanced";
|
||||
}
|
||||
if (
|
||||
values.topN === AUTOMATIC_PRESETS.detailed.topN &&
|
||||
values.minSuitabilityScore ===
|
||||
AUTOMATIC_PRESETS.detailed.minSuitabilityScore &&
|
||||
values.runBudget === AUTOMATIC_PRESETS.detailed.runBudget
|
||||
) {
|
||||
return "detailed";
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
open,
|
||||
settings,
|
||||
enabledSources,
|
||||
pipelineSources,
|
||||
onToggleSource,
|
||||
isPipelineRunning,
|
||||
onSaveAndRun,
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
||||
{
|
||||
defaultValues: {
|
||||
topN: String(DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
||||
runBudget: String(DEFAULT_VALUES.runBudget),
|
||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const topNInput = watch("topN");
|
||||
const minScoreInput = watch("minSuitabilityScore");
|
||||
const runBudgetInput = watch("runBudget");
|
||||
const searchTerms = watch("searchTerms");
|
||||
const searchTermDraft = watch("searchTermDraft");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const memory = loadAutomaticRunMemory();
|
||||
const topN = memory?.topN ?? DEFAULT_VALUES.topN;
|
||||
const minSuitabilityScore =
|
||||
memory?.minSuitabilityScore ?? DEFAULT_VALUES.minSuitabilityScore;
|
||||
|
||||
const rememberedRunBudget =
|
||||
settings?.jobspyResultsWanted ??
|
||||
settings?.gradcrackerMaxJobsPerTerm ??
|
||||
settings?.ukvisajobsMaxJobs ??
|
||||
DEFAULT_VALUES.runBudget;
|
||||
reset({
|
||||
topN: String(topN),
|
||||
minSuitabilityScore: String(minSuitabilityScore),
|
||||
runBudget: String(rememberedRunBudget),
|
||||
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
});
|
||||
setAdvancedOpen(false);
|
||||
}, [open, settings, reset]);
|
||||
|
||||
const addSearchTerms = (input: string) => {
|
||||
const parsed = parseSearchTermsInput(input);
|
||||
if (parsed.length === 0) return;
|
||||
const current = getValues("searchTerms");
|
||||
const next = [...current];
|
||||
for (const term of parsed) {
|
||||
if (!next.includes(term)) next.push(term);
|
||||
}
|
||||
setValue("searchTerms", next, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const values = useMemo<AutomaticRunValues>(() => {
|
||||
return {
|
||||
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: toNumber(
|
||||
minScoreInput,
|
||||
0,
|
||||
100,
|
||||
DEFAULT_VALUES.minSuitabilityScore,
|
||||
),
|
||||
searchTerms,
|
||||
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
|
||||
};
|
||||
}, [topNInput, minScoreInput, searchTerms, runBudgetInput]);
|
||||
|
||||
const estimate = useMemo(
|
||||
() => calculateAutomaticEstimate({ values, sources: pipelineSources }),
|
||||
[values, pipelineSources],
|
||||
);
|
||||
const activePreset = useMemo<AutomaticPresetSelection>(
|
||||
() => getPresetSelection(values),
|
||||
[values],
|
||||
);
|
||||
|
||||
const runDisabled =
|
||||
isPipelineRunning ||
|
||||
isSaving ||
|
||||
pipelineSources.length === 0 ||
|
||||
values.searchTerms.length === 0;
|
||||
|
||||
const applyPreset = (presetId: AutomaticPresetId) => {
|
||||
const preset = AUTOMATIC_PRESETS[presetId];
|
||||
setValue("topN", String(preset.topN), { shouldDirty: true });
|
||||
setValue("minSuitabilityScore", String(preset.minSuitabilityScore), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue("runBudget", String(preset.runBudget), { shouldDirty: true });
|
||||
};
|
||||
|
||||
const handleSaveAndRun = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
saveAutomaticRunMemory({
|
||||
topN: values.topN,
|
||||
minSuitabilityScore: values.minSuitabilityScore,
|
||||
});
|
||||
await onSaveAndRun(values);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="grid items-center gap-3 md:grid-cols-[120px_1fr]">
|
||||
<Label className="text-base font-semibold">Preset</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activePreset === "fast" ? "default" : "outline"}
|
||||
onClick={() => applyPreset("fast")}
|
||||
>
|
||||
Fast
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activePreset === "balanced" ? "default" : "outline"}
|
||||
onClick={() => applyPreset("balanced")}
|
||||
>
|
||||
Balanced
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activePreset === "detailed" ? "default" : "outline"}
|
||||
onClick={() => applyPreset("detailed")}
|
||||
>
|
||||
Detailed
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activePreset === "custom" ? "secondary" : "outline"}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={advancedOpen ? "advanced" : undefined}
|
||||
onValueChange={(value) => setAdvancedOpen(value === "advanced")}
|
||||
>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
<AccordionTrigger className="py-0 text-base font-semibold hover:no-underline">
|
||||
Advanced settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top-n">Resumes tailored</Label>
|
||||
<Input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={topNInput}
|
||||
onChange={(event) =>
|
||||
setValue("topN", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min-score">Min suitability score</Label>
|
||||
<Input
|
||||
id="min-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={minScoreInput}
|
||||
onChange={(event) =>
|
||||
setValue("minSuitabilityScore", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobs-per-term">Max jobs discovered</Label>
|
||||
<Input
|
||||
id="jobs-per-term"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={runBudgetInput}
|
||||
onChange={(event) =>
|
||||
setValue("runBudget", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Search terms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
id="search-terms-input"
|
||||
value={searchTermDraft}
|
||||
onChange={(event) =>
|
||||
setValue("searchTermDraft", event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
addSearchTerms(searchTermDraft);
|
||||
setValue("searchTermDraft", "");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Backspace" &&
|
||||
searchTermDraft.length === 0 &&
|
||||
searchTerms.length > 0
|
||||
) {
|
||||
setValue("searchTerms", searchTerms.slice(0, -1), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
addSearchTerms(searchTermDraft);
|
||||
setValue("searchTermDraft", "");
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
const parsed = parseSearchTermsInput(pasted);
|
||||
if (parsed.length > 1) {
|
||||
event.preventDefault();
|
||||
addSearchTerms(pasted);
|
||||
}
|
||||
}}
|
||||
placeholder="Type and press Enter"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add multiple terms by separating with commas or pressing Enter.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTerms.map((term) => (
|
||||
<button
|
||||
type="button"
|
||||
key={term}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-3 py-1 text-sm transition-all duration-150 hover:border-primary/50 hover:bg-primary/40 hover:text-primary-foreground hover:shadow-sm"
|
||||
aria-label={`Remove ${term}`}
|
||||
onClick={() =>
|
||||
setValue(
|
||||
"searchTerms",
|
||||
searchTerms.filter((value) => value !== term),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
{term}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
Sources ({pipelineSources.length}/{enabledSources.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{enabledSources.map((source) => (
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
pipelineSources.includes(source) ? "default" : "outline"
|
||||
}
|
||||
onClick={() =>
|
||||
onToggleSource(source, !pipelineSources.includes(source))
|
||||
}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-border/60 bg-background pt-3">
|
||||
<div className="hidden text-sm text-muted-foreground md:block">
|
||||
Est: {estimate.discovered.min}-{estimate.discovered.max} jobs, ~
|
||||
{values.topN} resumes
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="gap-2"
|
||||
disabled={runDisabled}
|
||||
onClick={() => void handleSaveAndRun()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
Start run now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -5,61 +5,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OrchestratorHeader } from "./OrchestratorHeader";
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
const React = require("react") as typeof import("react");
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div role="menu">{children}</div>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: (event: Event) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() =>
|
||||
onSelect?.({ preventDefault: () => {} } as unknown as Event)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({
|
||||
children,
|
||||
onCheckedChange,
|
||||
checked,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
checked?: boolean;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
@ -84,11 +29,7 @@ const renderHeader = (
|
||||
onNavOpenChange: vi.fn(),
|
||||
isPipelineRunning: false,
|
||||
pipelineSources: ["gradcracker"],
|
||||
enabledSources: ["gradcracker"],
|
||||
onToggleSource: vi.fn(),
|
||||
onSetPipelineSources: vi.fn(),
|
||||
onRunPipeline: vi.fn(),
|
||||
onOpenManualImport: vi.fn(),
|
||||
onOpenAutomaticRun: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@ -103,46 +44,16 @@ const renderHeader = (
|
||||
};
|
||||
|
||||
describe("OrchestratorHeader", () => {
|
||||
it("renders only enabled sources", () => {
|
||||
renderHeader({
|
||||
enabledSources: ["gradcracker", "linkedin"],
|
||||
pipelineSources: ["linkedin"],
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i }),
|
||||
).not.toBeInTheDocument();
|
||||
it("opens automatic run from the navbar button", () => {
|
||||
const { props } = renderHeader();
|
||||
fireEvent.click(screen.getByRole("button", { name: /run pipeline/i }));
|
||||
expect(props.onOpenAutomaticRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses enabled sources for the all sources action", () => {
|
||||
const { props } = renderHeader({
|
||||
enabledSources: ["gradcracker", "linkedin"],
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("menuitemcheckbox", { name: /Select all sources/i }),
|
||||
);
|
||||
|
||||
expect(props.onSetPipelineSources).toHaveBeenCalledWith([
|
||||
"gradcracker",
|
||||
"linkedin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not show source presets", () => {
|
||||
renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
|
||||
|
||||
it("does not render manual import button", () => {
|
||||
renderHeader();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Gradcracker only/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i }),
|
||||
screen.queryByRole("button", { name: /manual import/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,9 @@
|
||||
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import {
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Loader2,
|
||||
Menu,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Menu, Play, Sparkles } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -26,19 +11,14 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn, sourceLabel } from "@/lib/utils";
|
||||
import { orderedSources } from "./constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrchestratorHeaderProps {
|
||||
navOpen: boolean;
|
||||
onNavOpenChange: (open: boolean) => void;
|
||||
isPipelineRunning: boolean;
|
||||
pipelineSources: JobSource[];
|
||||
enabledSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||
onRunPipeline: () => void;
|
||||
onOpenManualImport: () => void;
|
||||
onOpenAutomaticRun: () => void;
|
||||
}
|
||||
|
||||
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
@ -46,20 +26,10 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
onNavOpenChange,
|
||||
isPipelineRunning,
|
||||
pipelineSources,
|
||||
enabledSources,
|
||||
onToggleSource,
|
||||
onSetPipelineSources,
|
||||
onRunPipeline,
|
||||
onOpenManualImport,
|
||||
onOpenAutomaticRun,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const visibleSources = orderedSources.filter((source) =>
|
||||
enabledSources.includes(source),
|
||||
);
|
||||
const allSourcesSelected =
|
||||
visibleSources.length > 0 &&
|
||||
visibleSources.every((source) => pipelineSources.includes(source));
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
@ -126,74 +96,21 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenManualImport}
|
||||
onClick={onOpenAutomaticRun}
|
||||
disabled={isPipelineRunning}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Manual import</span>
|
||||
{isPipelineRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{isPipelineRunning
|
||||
? `Running (${pipelineSources.length})`
|
||||
: `Run pipeline`}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onRunPipeline}
|
||||
disabled={isPipelineRunning}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPipelineRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{isPipelineRunning
|
||||
? `Running (${pipelineSources.length})`
|
||||
: `Run pipeline (${pipelineSources.length})`}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPipelineRunning}
|
||||
aria-label="Select pipeline sources"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{visibleSources.map((source) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source}
|
||||
checked={pipelineSources.includes(source)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleSource(source, Boolean(checked))
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={allSourcesSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
onSetPipelineSources(
|
||||
checked ? visibleSources : visibleSources.slice(0, 1),
|
||||
);
|
||||
}}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
Select all sources
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RunModeModal } from "./RunModeModal";
|
||||
|
||||
vi.mock("@client/components/ManualImportFlow", () => ({
|
||||
ManualImportFlow: () => <div data-testid="manual-flow">Manual flow</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./AutomaticRunTab", () => ({
|
||||
AutomaticRunTab: () => (
|
||||
<div data-testid="automatic-tab">Automatic run tab</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("RunModeModal", () => {
|
||||
it("switches between Automatic and Manual tabs", () => {
|
||||
render(
|
||||
<RunModeModal
|
||||
open
|
||||
mode="automatic"
|
||||
settings={null}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onModeChange={vi.fn()}
|
||||
onSaveAndRunAutomatic={vi.fn().mockResolvedValue(undefined)}
|
||||
onManualImported={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("automatic-tab")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /manual/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
93
orchestrator/src/client/pages/orchestrator/RunModeModal.tsx
Normal file
93
orchestrator/src/client/pages/orchestrator/RunModeModal.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { ManualImportFlow } from "@client/components/ManualImportFlow";
|
||||
import type { AppSettings, JobSource } from "@shared/types";
|
||||
import type React from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AutomaticRunTab } from "./AutomaticRunTab";
|
||||
import type { AutomaticRunValues } from "./automatic-run";
|
||||
import type { RunMode } from "./run-mode";
|
||||
|
||||
interface RunModeModalProps {
|
||||
open: boolean;
|
||||
mode: RunMode;
|
||||
settings: AppSettings | null;
|
||||
enabledSources: JobSource[];
|
||||
pipelineSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
isPipelineRunning: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onModeChange: (mode: RunMode) => void;
|
||||
onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
|
||||
onManualImported: (jobId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const RunModeModal: React.FC<RunModeModalProps> = ({
|
||||
open,
|
||||
mode,
|
||||
settings,
|
||||
enabledSources,
|
||||
pipelineSources,
|
||||
onToggleSource,
|
||||
isPipelineRunning,
|
||||
onOpenChange,
|
||||
onModeChange,
|
||||
onSaveAndRunAutomatic,
|
||||
onManualImported,
|
||||
}) => {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
Run jobs
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Choose Automatic pipeline run or Manual import.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as RunMode)}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="automatic">Automatic</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="automatic" className="min-h-0 flex-1">
|
||||
<AutomaticRunTab
|
||||
open={open}
|
||||
settings={settings}
|
||||
enabledSources={enabledSources}
|
||||
pipelineSources={pipelineSources}
|
||||
onToggleSource={onToggleSource}
|
||||
isPipelineRunning={isPipelineRunning}
|
||||
onSaveAndRun={onSaveAndRunAutomatic}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="min-h-0 flex-1">
|
||||
<ManualImportFlow
|
||||
active={open && mode === "manual"}
|
||||
onImported={onManualImported}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTOMATIC_PRESETS,
|
||||
calculateAutomaticEstimate,
|
||||
deriveExtractorLimits,
|
||||
parseSearchTermsInput,
|
||||
} from "./automatic-run";
|
||||
|
||||
describe("automatic-run utilities", () => {
|
||||
it("exposes the expected preset values", () => {
|
||||
expect(AUTOMATIC_PRESETS.fast).toEqual({
|
||||
topN: 5,
|
||||
minSuitabilityScore: 75,
|
||||
runBudget: 300,
|
||||
});
|
||||
|
||||
expect(AUTOMATIC_PRESETS.detailed.topN).toBeGreaterThan(
|
||||
AUTOMATIC_PRESETS.fast.topN,
|
||||
);
|
||||
});
|
||||
|
||||
it("calculates estimate range with source caps and topN clipping", () => {
|
||||
const estimate = calculateAutomaticEstimate({
|
||||
values: {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 100,
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
|
||||
expect(estimate.discovered.cap).toBe(100);
|
||||
expect(estimate.discovered.min).toBe(35);
|
||||
expect(estimate.discovered.max).toBe(75);
|
||||
expect(estimate.processed.min).toBe(10);
|
||||
expect(estimate.processed.max).toBe(10);
|
||||
});
|
||||
|
||||
it("keeps discovered cap under budget regardless of search-term count", () => {
|
||||
const limits = deriveExtractorLimits({
|
||||
budget: 750,
|
||||
searchTerms: ["a", "b", "c"],
|
||||
sources: ["indeed", "linkedin", "gradcracker"],
|
||||
});
|
||||
|
||||
const cap =
|
||||
2 * limits.jobspyResultsWanted * 3 + limits.gradcrackerMaxJobsPerTerm * 3;
|
||||
|
||||
expect(cap).toBeLessThanOrEqual(750);
|
||||
});
|
||||
|
||||
it("returns zero estimate when no search terms are provided", () => {
|
||||
const estimate = calculateAutomaticEstimate({
|
||||
values: {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
searchTerms: [],
|
||||
runBudget: 750,
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
|
||||
expect(estimate).toEqual({
|
||||
discovered: { min: 0, max: 0, cap: 0 },
|
||||
processed: { min: 0, max: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses comma and newline separated search terms", () => {
|
||||
expect(parseSearchTermsInput("backend, platform\napi\n\n")).toEqual([
|
||||
"backend",
|
||||
"platform",
|
||||
"api",
|
||||
]);
|
||||
});
|
||||
});
|
||||
196
orchestrator/src/client/pages/orchestrator/automatic-run.ts
Normal file
196
orchestrator/src/client/pages/orchestrator/automatic-run.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import type { JobSource } from "@shared/types";
|
||||
|
||||
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
|
||||
|
||||
export interface AutomaticRunValues {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
}
|
||||
|
||||
export interface AutomaticPresetValues {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
runBudget: number;
|
||||
}
|
||||
|
||||
export interface AutomaticEstimate {
|
||||
discovered: {
|
||||
min: number;
|
||||
max: number;
|
||||
cap: number;
|
||||
};
|
||||
processed: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const AUTOMATIC_PRESETS: Record<
|
||||
AutomaticPresetId,
|
||||
AutomaticPresetValues
|
||||
> = {
|
||||
fast: {
|
||||
topN: 5,
|
||||
minSuitabilityScore: 75,
|
||||
runBudget: 300,
|
||||
},
|
||||
balanced: {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
runBudget: 500,
|
||||
},
|
||||
detailed: {
|
||||
topN: 20,
|
||||
minSuitabilityScore: 35,
|
||||
runBudget: 750,
|
||||
},
|
||||
};
|
||||
|
||||
export const RUN_MEMORY_STORAGE_KEY = "jobops.pipeline.run-memory.v1";
|
||||
|
||||
export interface AutomaticRunMemory {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
}
|
||||
|
||||
export interface ExtractorLimits {
|
||||
jobspyResultsWanted: number;
|
||||
gradcrackerMaxJobsPerTerm: number;
|
||||
ukvisajobsMaxJobs: number;
|
||||
}
|
||||
|
||||
export function deriveExtractorLimits(args: {
|
||||
budget: number;
|
||||
searchTerms: string[];
|
||||
sources: JobSource[];
|
||||
}): ExtractorLimits {
|
||||
const budget = Math.max(1, Math.round(args.budget));
|
||||
const termCount = Math.max(1, args.searchTerms.length);
|
||||
const includesIndeed = args.sources.includes("indeed");
|
||||
const includesLinkedIn = args.sources.includes("linkedin");
|
||||
const includesGradcracker = args.sources.includes("gradcracker");
|
||||
const includesUkVisaJobs = args.sources.includes("ukvisajobs");
|
||||
|
||||
const weightedContributors =
|
||||
(includesIndeed ? termCount : 0) +
|
||||
(includesLinkedIn ? termCount : 0) +
|
||||
(includesGradcracker ? termCount : 0) +
|
||||
(includesUkVisaJobs ? 1 : 0);
|
||||
|
||||
if (weightedContributors <= 0) {
|
||||
return {
|
||||
jobspyResultsWanted: budget,
|
||||
gradcrackerMaxJobsPerTerm: budget,
|
||||
ukvisajobsMaxJobs: budget,
|
||||
};
|
||||
}
|
||||
|
||||
const perUnit = Math.max(1, Math.floor(budget / weightedContributors));
|
||||
const remainder = Math.max(0, budget - perUnit * weightedContributors);
|
||||
|
||||
return {
|
||||
jobspyResultsWanted: perUnit,
|
||||
gradcrackerMaxJobsPerTerm: perUnit,
|
||||
ukvisajobsMaxJobs: Math.min(budget, perUnit + remainder),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSearchTermsInput(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]/g)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function stringifySearchTerms(terms: string[]): string {
|
||||
return terms.join("\n");
|
||||
}
|
||||
|
||||
export function calculateAutomaticEstimate(args: {
|
||||
values: AutomaticRunValues;
|
||||
sources: JobSource[];
|
||||
}): AutomaticEstimate {
|
||||
const { values, sources } = args;
|
||||
if (values.searchTerms.length === 0) {
|
||||
return {
|
||||
discovered: {
|
||||
min: 0,
|
||||
max: 0,
|
||||
cap: 0,
|
||||
},
|
||||
processed: {
|
||||
min: 0,
|
||||
max: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const termCount = values.searchTerms.length;
|
||||
const hasGradcracker = sources.includes("gradcracker");
|
||||
const hasUkVisaJobs = sources.includes("ukvisajobs");
|
||||
const hasIndeed = sources.includes("indeed");
|
||||
const hasLinkedIn = sources.includes("linkedin");
|
||||
const limits = deriveExtractorLimits({
|
||||
budget: values.runBudget,
|
||||
searchTerms: values.searchTerms,
|
||||
sources,
|
||||
});
|
||||
|
||||
const jobspySitesCount = [hasIndeed, hasLinkedIn].filter(Boolean).length;
|
||||
const jobspyCap = jobspySitesCount * limits.jobspyResultsWanted * termCount;
|
||||
const gradcrackerCap = hasGradcracker
|
||||
? limits.gradcrackerMaxJobsPerTerm * termCount
|
||||
: 0;
|
||||
const ukvisaCap = hasUkVisaJobs ? limits.ukvisajobsMaxJobs : 0;
|
||||
|
||||
const discoveredCap = jobspyCap + gradcrackerCap + ukvisaCap;
|
||||
const discoveredMin = Math.round(discoveredCap * 0.35);
|
||||
const discoveredMax = Math.round(discoveredCap * 0.75);
|
||||
const processedMin = Math.min(values.topN, discoveredMin);
|
||||
const processedMax = Math.min(values.topN, discoveredMax);
|
||||
|
||||
return {
|
||||
discovered: {
|
||||
min: discoveredMin,
|
||||
max: discoveredMax,
|
||||
cap: discoveredCap,
|
||||
},
|
||||
processed: {
|
||||
min: processedMin,
|
||||
max: processedMax,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAutomaticRunMemory(): AutomaticRunMemory | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(RUN_MEMORY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<AutomaticRunMemory>;
|
||||
if (
|
||||
typeof parsed.topN !== "number" ||
|
||||
typeof parsed.minSuitabilityScore !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
topN: Math.min(50, Math.max(1, Math.round(parsed.topN))),
|
||||
minSuitabilityScore: Math.min(
|
||||
100,
|
||||
Math.max(0, Math.round(parsed.minSuitabilityScore)),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAutomaticRunMemory(memory: AutomaticRunMemory): void {
|
||||
try {
|
||||
localStorage.setItem(RUN_MEMORY_STORAGE_KEY, JSON.stringify(memory));
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
}
|
||||
}
|
||||
1
orchestrator/src/client/pages/orchestrator/run-mode.ts
Normal file
1
orchestrator/src/client/pages/orchestrator/run-mode.ts
Normal file
@ -0,0 +1 @@
|
||||
export type RunMode = "automatic" | "manual";
|
||||
@ -1,7 +1,8 @@
|
||||
import { okWithMeta } from "@infra/http";
|
||||
import { AppError, badRequest, requestTimeout } from "@infra/errors";
|
||||
import { fail, ok, okWithMeta } from "@infra/http";
|
||||
import { logger } from "@infra/logger";
|
||||
import { runWithRequestContext } from "@infra/request-context";
|
||||
import type { ApiResponse, PipelineStatusResponse } from "@shared/types";
|
||||
import type { PipelineStatusResponse } from "@shared/types";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { isDemoMode } from "../../config/demo";
|
||||
@ -22,22 +23,21 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { isRunning } = getPipelineStatus();
|
||||
const lastRun = await pipelineRepo.getLatestPipelineRun();
|
||||
|
||||
const response: ApiResponse<PipelineStatusResponse> = {
|
||||
ok: true,
|
||||
data: {
|
||||
isRunning,
|
||||
lastRun,
|
||||
nextScheduledRun: null, // Would come from n8n
|
||||
},
|
||||
const data: PipelineStatusResponse = {
|
||||
isRunning,
|
||||
lastRun,
|
||||
nextScheduledRun: null,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
ok(res, data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
fail(
|
||||
res,
|
||||
new AppError({
|
||||
status: 500,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -77,12 +77,16 @@ pipelineRouter.get("/progress", (req: Request, res: Response) => {
|
||||
pipelineRouter.get("/runs", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const runs = await pipelineRepo.getRecentPipelineRuns(20);
|
||||
res.json({ ok: true, data: runs });
|
||||
ok(res, runs);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
fail(
|
||||
res,
|
||||
new AppError({
|
||||
status: 500,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -113,21 +117,21 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
|
||||
logger.error("Background pipeline run failed", error);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
data: { message: "Pipeline started" },
|
||||
});
|
||||
ok(res, { message: "Pipeline started" });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: error.message },
|
||||
});
|
||||
return fail(res, badRequest(error.message, error.flatten()));
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return fail(res, requestTimeout("Request timed out"));
|
||||
}
|
||||
fail(
|
||||
res,
|
||||
new AppError({
|
||||
status: 500,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user