Merge pull request #30 from anasyd/main
feat: add option to fetch from a link
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user