diff --git a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx new file mode 100644 index 0000000..a24cac0 --- /dev/null +++ b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx @@ -0,0 +1,154 @@ +import type { Job } from "@shared/types.js"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../api"; +import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; + +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + SheetContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + SheetDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +vi.mock("../api", () => ({ + updateJob: vi.fn(), + checkSponsor: vi.fn(), + rescoreJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => + ({ + id: "job-1", + title: "Backend Engineer", + employer: "Acme", + jobUrl: "https://example.com/job", + applicationLink: null, + location: "London", + salary: null, + deadline: null, + jobDescription: "Build APIs", + ...overrides, + }) as Job; + +describe("JobDetailsEditDrawer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("saves details and reruns sponsor check when employer changes", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const onOpenChange = vi.fn(); + vi.mocked(api.updateJob).mockResolvedValue({} as Job); + vi.mocked(api.checkSponsor).mockResolvedValue({} as Job); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Employer *"), { + target: { value: "NewCo" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /save details/i })); + + await waitFor(() => + expect(api.updateJob).toHaveBeenCalledWith( + "job-1", + expect.objectContaining({ + employer: "NewCo", + title: "Backend Engineer", + }), + ), + ); + expect(api.checkSponsor).toHaveBeenCalledWith("job-1"); + expect(onJobUpdated).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("validates required fields before saving", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const onOpenChange = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Title *"), { + target: { value: " " }, + }); + + fireEvent.click(screen.getByRole("button", { name: /save details/i })); + + expect(await screen.findByText("Title is required.")).toBeInTheDocument(); + expect(api.updateJob).not.toHaveBeenCalled(); + expect(onJobUpdated).not.toHaveBeenCalled(); + }); + + it("offers a rescore action after successful save", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const onOpenChange = vi.fn(); + const { toast } = await import("sonner"); + vi.mocked(api.updateJob).mockResolvedValue({} as Job); + vi.mocked(api.rescoreJob).mockResolvedValue({} as Job); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Salary"), { + target: { value: "GBP 90k" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save details/i })); + + await waitFor(() => + expect(vi.mocked(toast.success)).toHaveBeenCalledWith( + "Job details updated", + expect.any(Object), + ), + ); + + const successCalls = vi.mocked(toast.success).mock.calls; + const [, payload] = + successCalls.find((call) => call[0] === "Job details updated") ?? []; + expect(payload).toBeTruthy(); + + (payload as { action?: { onClick?: () => void } }).action?.onClick?.(); + + await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1")); + expect(onJobUpdated).toHaveBeenCalledTimes(2); + }); +}); diff --git a/orchestrator/src/client/components/JobDetailsEditDrawer.tsx b/orchestrator/src/client/components/JobDetailsEditDrawer.tsx new file mode 100644 index 0000000..cd32f6a --- /dev/null +++ b/orchestrator/src/client/components/JobDetailsEditDrawer.tsx @@ -0,0 +1,360 @@ +import type { Job } from "@shared/types.js"; +import { Loader2, Save } 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 { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Textarea } from "@/components/ui/textarea"; +import * as api from "../api"; + +interface JobDetailsEditDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + job: Job | null; + onJobUpdated: () => void | Promise; +} + +type JobDetailsDraft = { + title: string; + employer: string; + jobUrl: string; + applicationLink: string; + location: string; + salary: string; + deadline: string; + jobDescription: string; +}; + +const emptyDraft: JobDetailsDraft = { + title: "", + employer: "", + jobUrl: "", + applicationLink: "", + location: "", + salary: "", + deadline: "", + jobDescription: "", +}; + +const normalizeOptional = (value: string): string | null => { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const normalizeFromJob = (job: Job | null): JobDetailsDraft => { + if (!job) return emptyDraft; + return { + title: job.title ?? "", + employer: job.employer ?? "", + jobUrl: job.jobUrl ?? "", + applicationLink: job.applicationLink ?? "", + location: job.location ?? "", + salary: job.salary ?? "", + deadline: job.deadline ?? "", + jobDescription: job.jobDescription ?? "", + }; +}; + +function isValidUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} + +export const JobDetailsEditDrawer: React.FC = ({ + open, + onOpenChange, + job, + onJobUpdated, +}) => { + const [draft, setDraft] = useState(emptyDraft); + const [validationError, setValidationError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!open) return; + setDraft(normalizeFromJob(job)); + setValidationError(null); + setIsSaving(false); + }, [job, open]); + + const hasJob = !!job; + const isDirty = useMemo(() => { + if (!job) return false; + const current = normalizeFromJob(job); + return ( + draft.title !== current.title || + draft.employer !== current.employer || + draft.jobUrl !== current.jobUrl || + draft.applicationLink !== current.applicationLink || + draft.location !== current.location || + draft.salary !== current.salary || + draft.deadline !== current.deadline || + draft.jobDescription !== current.jobDescription + ); + }, [draft, job]); + + const handleSave = async () => { + if (!job) return; + + const title = draft.title.trim(); + const employer = draft.employer.trim(); + const jobUrl = draft.jobUrl.trim(); + const applicationLink = draft.applicationLink.trim(); + + if (!title) { + setValidationError("Title is required."); + return; + } + if (!employer) { + setValidationError("Employer is required."); + return; + } + if (!jobUrl) { + setValidationError("Job URL is required."); + return; + } + if (!isValidUrl(jobUrl)) { + setValidationError("Job URL must be a valid URL."); + return; + } + if (applicationLink && !isValidUrl(applicationLink)) { + setValidationError("Application URL must be a valid URL."); + return; + } + + try { + setValidationError(null); + setIsSaving(true); + + const employerChanged = + employer.toLowerCase() !== job.employer.trim().toLowerCase(); + + await api.updateJob(job.id, { + title, + employer, + jobUrl, + applicationLink: normalizeOptional(draft.applicationLink), + location: normalizeOptional(draft.location), + salary: normalizeOptional(draft.salary), + deadline: normalizeOptional(draft.deadline), + jobDescription: normalizeOptional(draft.jobDescription), + }); + + if (employerChanged) { + try { + await api.checkSponsor(job.id); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Job updated, but sponsor check failed"; + toast.error(message); + } + } + + await onJobUpdated(); + + toast.success("Job details updated", { + action: { + label: "Rescore now", + onClick: () => { + void (async () => { + try { + await api.rescoreJob(job.id); + await onJobUpdated(); + toast.success("Match recalculated"); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to recalculate match"; + toast.error(message); + } + })(); + }, + }, + }); + + onOpenChange(false); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update job details"; + toast.error(message); + } finally { + setIsSaving(false); + } + }; + + return ( + + +
+ + Edit job details + + Correct extracted metadata before continuing with this role. + + + + {!hasJob ? ( +
+ Select a job to edit. +
+ ) : ( + <> +
+
+ + setDraft((prev) => ({ ...prev, title: value })) + } + placeholder="e.g. Full Stack Engineer" + /> + + setDraft((prev) => ({ ...prev, employer: value })) + } + placeholder="e.g. Acme Labs" + /> + + setDraft((prev) => ({ ...prev, jobUrl: value })) + } + placeholder="https://..." + /> + + setDraft((prev) => ({ ...prev, applicationLink: value })) + } + placeholder="https://..." + /> + + setDraft((prev) => ({ ...prev, location: value })) + } + placeholder="e.g. London, UK" + /> + + setDraft((prev) => ({ ...prev, salary: value })) + } + placeholder="e.g. GBP 90k-110k" + /> + + setDraft((prev) => ({ ...prev, deadline: value })) + } + placeholder="e.g. 31 Mar 2026" + /> +
+ +
+ +