Merge pull request #30 from anasyd/main

feat: add option to fetch from a link
This commit is contained in:
Shaheer Sarfaraz
2026-01-24 13:47:32 +00:00
committed by GitHub
9 changed files with 444 additions and 32 deletions
+20 -1
View File
@@ -16,6 +16,7 @@ import type {
CreateJobInput,
ManualJobDraft,
ManualJobInferenceResponse,
ManualJobFetchResponse,
VisaSponsorSearchResponse,
VisaSponsorStatusResponse,
VisaSponsor,
@@ -39,7 +40,16 @@ async function fetchApi<T>(
},
});
const data: ApiResponse<T> = await response.json();
const text = await response.text();
let data: ApiResponse<T>;
try {
data = JSON.parse(text);
} catch {
// If the response is not JSON, it's likely an HTML error page
console.error('API returned non-JSON response:', text.substring(0, 500));
throw new Error(`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`);
}
if (!data.success) {
throw new Error(data.error || 'API request failed');
@@ -149,6 +159,15 @@ export async function importUkVisaJobs(input: {
}
// Manual Job Import API
export async function fetchJobFromUrl(input: {
url: string;
}): Promise<ManualJobFetchResponse> {
return fetchApi<ManualJobFetchResponse>('/manual-jobs/fetch', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function inferManualJob(input: {
jobDescription: string;
}): Promise<ManualJobInferenceResponse> {
@@ -6,6 +6,7 @@ import * as api from "../api";
import { toast } from "sonner";
vi.mock("../api", () => ({
fetchJobFromUrl: vi.fn(),
inferManualJob: vi.fn(),
importManualJob: vi.fn(),
}));
@@ -41,7 +42,7 @@ describe("ManualImportSheet", () => {
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here..."),
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: rawDescription } }
);
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
@@ -92,7 +93,7 @@ describe("ManualImportSheet", () => {
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here..."),
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: rawDescription } }
);
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
@@ -122,7 +123,7 @@ describe("ManualImportSheet", () => {
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here..."),
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: rawDescription } }
);
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
@@ -150,7 +151,7 @@ describe("ManualImportSheet", () => {
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here..."),
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: "Backend Engineer role." } }
);
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
@@ -165,4 +166,137 @@ describe("ManualImportSheet", () => {
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled();
});
describe("URL fetch functionality", () => {
it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => {
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
);
// Initially should show Paste button
expect(screen.getByRole("button", { name: /paste/i })).toBeInTheDocument();
// Enter a URL
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } }
);
// Should now show Fetch button
expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument();
});
it("fetches URL and proceeds to review on successful fetch", async () => {
vi.mocked(api.fetchJobFromUrl).mockResolvedValue({
content: "Software Engineer role at Acme Corp",
url: "https://example.com/job",
});
vi.mocked(api.inferManualJob).mockResolvedValue({
job: {
title: "Software Engineer",
employer: "Acme Corp",
location: "Remote",
jobDescription: "Great opportunity to join our team.",
},
});
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
);
// Enter a URL
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } }
);
// Click Fetch
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
// Should show loading state then review
await screen.findByPlaceholderText("e.g. Junior Backend Engineer");
expect(api.fetchJobFromUrl).toHaveBeenCalledWith({
url: "https://example.com/job",
});
expect(api.inferManualJob).toHaveBeenCalledWith({
jobDescription: "Software Engineer role at Acme Corp",
});
// Check inferred values are shown
expect(screen.getByPlaceholderText("e.g. Junior Backend Engineer")).toHaveValue("Software Engineer");
expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue("Acme Corp");
});
it("preserves fetched URL in the job URL field", async () => {
vi.mocked(api.fetchJobFromUrl).mockResolvedValue({
content: "Job description content",
url: "https://example.com/job",
});
vi.mocked(api.inferManualJob).mockResolvedValue({
job: {
title: "Engineer",
employer: "Company",
},
});
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
);
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } }
);
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
await screen.findByPlaceholderText("e.g. Junior Backend Engineer");
// Check the job URL field has the fetched URL (first https://... input is Job URL)
const urlInputs = screen.getAllByPlaceholderText("https://...");
expect(urlInputs[0]).toHaveValue("https://example.com/job");
});
it("shows error and returns to paste step when fetch fails", async () => {
vi.mocked(api.fetchJobFromUrl).mockRejectedValue(new Error("Failed to fetch URL"));
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
);
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/bad-url" } }
);
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
await screen.findByText("Failed to fetch URL");
// Should still be on paste step
expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument();
});
it("shows error when inference fails after fetch", async () => {
vi.mocked(api.fetchJobFromUrl).mockResolvedValue({
content: "Job content",
url: "https://example.com/job",
});
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed"));
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
);
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } }
);
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
await screen.findByText("Inference failed");
// Should be back on paste step
expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument();
});
});
});
@@ -3,7 +3,7 @@
*/
import React, { useEffect, useMemo, useState } from "react";
import { ArrowLeft, FileText, Loader2, Sparkles } from "lucide-react";
import { ArrowLeft, ClipboardPaste, FileText, Link, Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -112,6 +112,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
}) => {
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);
@@ -121,6 +123,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
if (!open) {
setStep("paste");
setRawDescription("");
setFetchUrl("");
setIsFetching(false);
setDraft(emptyDraft);
setWarning(null);
setError(null);
@@ -132,6 +136,7 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
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 (
@@ -141,6 +146,43 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
);
}, [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.");
@@ -152,7 +194,12 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setWarning(null);
setStep("loading");
const response = await api.inferManualJob({ jobDescription: rawDescription });
setDraft(normalizeDraft(response.job, rawDescription.trim()));
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) {
@@ -217,6 +264,53 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<div className="mt-4 flex-1 overflow-y-auto pr-1">
{step === "paste" && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job URL (optional)
</label>
<div className="flex gap-2">
<Input
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 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job description
@@ -224,8 +318,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<Textarea
value={rawDescription}
onChange={(event) => setRawDescription(event.target.value)}
placeholder="Paste the full job description here..."
className="min-h-[220px] font-mono text-sm leading-relaxed"
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>
@@ -236,12 +330,16 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
)}
<Button
onClick={handleAnalyze}
disabled={!canAnalyze}
onClick={fetchUrl.trim() ? handleFetch : handleAnalyze}
disabled={isFetching || (!canFetch && !canAnalyze)}
className="w-full h-10 gap-2"
>
<Sparkles className="h-4 w-4" />
Analyze JD
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isFetching ? "Fetching..." : "Analyze JD"}
</Button>
</div>
)}