Edit job details in UI (#119)
* api(jobs): normalize PATCH /jobs/:id response contract and error mapping * api(jobs): support core job detail edits in update schema * feat(client): add JobDetailsEditDrawer with core metadata form * feat(orchestrator): open edit drawer from JobDetailPanel more actions * feat(orchestrator): add edit drawer trigger to ready and discovered more actions
This commit is contained in:
parent
b456ab1951
commit
d82c69b4b0
154
orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx
Normal file
154
orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx
Normal file
@ -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 ? <div>{children}</div> : null,
|
||||||
|
SheetContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
SheetHeader: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
SheetTitle: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<h2>{children}</h2>
|
||||||
|
),
|
||||||
|
SheetDescription: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<p>{children}</p>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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> = {}): 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(
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
job={createJob()}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
job={createJob()}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
job={createJob()}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
360
orchestrator/src/client/components/JobDetailsEditDrawer.tsx
Normal file
360
orchestrator/src/client/components/JobDetailsEditDrawer.tsx
Normal file
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<JobDetailsEditDrawerProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
job,
|
||||||
|
onJobUpdated,
|
||||||
|
}) => {
|
||||||
|
const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-2xl">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Edit job details</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Correct extracted metadata before continuing with this role.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{!hasJob ? (
|
||||||
|
<div className="mt-6 rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||||
|
Select a job to edit.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex-1 overflow-y-auto pr-1">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<FieldInput
|
||||||
|
id="edit-job-title"
|
||||||
|
label="Title *"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, title: value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Full Stack Engineer"
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
id="edit-job-employer"
|
||||||
|
label="Employer *"
|
||||||
|
value={draft.employer}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, employer: value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Acme Labs"
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
id="edit-job-url"
|
||||||
|
label="Job URL *"
|
||||||
|
value={draft.jobUrl}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, jobUrl: value }))
|
||||||
|
}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
id="edit-application-url"
|
||||||
|
label="Application URL"
|
||||||
|
value={draft.applicationLink}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, applicationLink: value }))
|
||||||
|
}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
id="edit-location"
|
||||||
|
label="Location"
|
||||||
|
value={draft.location}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, location: value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. London, UK"
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
id="edit-salary"
|
||||||
|
label="Salary"
|
||||||
|
value={draft.salary}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, salary: value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. GBP 90k-110k"
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
id="edit-deadline"
|
||||||
|
label="Deadline"
|
||||||
|
value={draft.deadline}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, deadline: value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. 31 Mar 2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="edit-job-description"
|
||||||
|
className="text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Job description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-job-description"
|
||||||
|
value={draft.jobDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jobDescription: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Paste or refine the job description..."
|
||||||
|
className="min-h-[220px] font-mono text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{validationError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={isSaving || !isDirty}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
@ -57,6 +57,31 @@ vi.mock("../api", () => ({
|
|||||||
updateJob: vi.fn(),
|
updateJob: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./JobDetailsEditDrawer", () => ({
|
||||||
|
JobDetailsEditDrawer: ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onJobUpdated,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onJobUpdated: () => void | Promise<void>;
|
||||||
|
}) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="job-details-edit-drawer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onJobUpdated();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
toast: {
|
toast: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
@ -156,4 +181,28 @@ describe("ReadyPanel", () => {
|
|||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens edit details drawer from more actions", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const job = createJob();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ReadyPanel
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /edit details/i }));
|
||||||
|
expect(screen.getByTestId("job-details-edit-drawer")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
|
||||||
|
await waitFor(() => expect(onJobUpdated).toHaveBeenCalled());
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("job-details-edit-drawer"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import { useProfile } from "../hooks/useProfile";
|
|||||||
import { useRescoreJob } from "../hooks/useRescoreJob";
|
import { useRescoreJob } from "../hooks/useRescoreJob";
|
||||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||||
|
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||||
|
|
||||||
type PanelMode = "ready" | "tailor";
|
type PanelMode = "ready" | "tailor";
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
const [mode, setMode] = useState<PanelMode>("ready");
|
const [mode, setMode] = useState<PanelMode>("ready");
|
||||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
const [isEditDetailsOpen, setIsEditDetailsOpen] = useState(false);
|
||||||
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
||||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||||
const [recentlyApplied, setRecentlyApplied] = useState<{
|
const [recentlyApplied, setRecentlyApplied] = useState<{
|
||||||
@ -90,6 +92,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
if (previousJobIdRef.current === currentJobId) return;
|
if (previousJobIdRef.current === currentJobId) return;
|
||||||
previousJobIdRef.current = currentJobId;
|
previousJobIdRef.current = currentJobId;
|
||||||
setMode("ready");
|
setMode("ready");
|
||||||
|
setIsEditDetailsOpen(false);
|
||||||
onTailoringDirtyChange?.(false);
|
onTailoringDirtyChange?.(false);
|
||||||
}, [job?.id, onTailoringDirtyChange]);
|
}, [job?.id, onTailoringDirtyChange]);
|
||||||
|
|
||||||
@ -413,6 +416,10 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
<Edit2 className="mr-2 h-4 w-4" />
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
Edit tailoring
|
Edit tailoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setIsEditDetailsOpen(true)}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
Edit details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={handleRegenerate}
|
onSelect={handleRegenerate}
|
||||||
@ -453,6 +460,13 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open={isEditDetailsOpen}
|
||||||
|
onOpenChange={setIsEditDetailsOpen}
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ─────────────────────────────────────────────────────────────────────
|
{/* ─────────────────────────────────────────────────────────────────────
|
||||||
UNDO BAR (conditional)
|
UNDO BAR (conditional)
|
||||||
Lightweight undo option after marking applied
|
Lightweight undo option after marking applied
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import {
|
import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Edit2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
@ -28,6 +29,7 @@ interface DecideModeProps {
|
|||||||
isSkipping: boolean;
|
isSkipping: boolean;
|
||||||
onRescore: () => void;
|
onRescore: () => void;
|
||||||
isRescoring: boolean;
|
isRescoring: boolean;
|
||||||
|
onEditDetails: () => void;
|
||||||
onCheckSponsor?: () => Promise<void>;
|
onCheckSponsor?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +40,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
isSkipping,
|
isSkipping,
|
||||||
onRescore,
|
onRescore,
|
||||||
isRescoring,
|
isRescoring,
|
||||||
|
onEditDetails,
|
||||||
onCheckSponsor,
|
onCheckSponsor,
|
||||||
}) => {
|
}) => {
|
||||||
const [showDescription, setShowDescription] = useState(false);
|
const [showDescription, setShowDescription] = useState(false);
|
||||||
@ -113,6 +116,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="center" className="w-56">
|
<DropdownMenuContent align="center" className="w-56">
|
||||||
|
<DropdownMenuItem onSelect={onEditDetails}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
Edit details
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
|
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
|
||||||
<RefreshCcw
|
<RefreshCcw
|
||||||
className={
|
className={
|
||||||
|
|||||||
@ -50,6 +50,31 @@ vi.mock("../../api", () => ({
|
|||||||
checkSponsor: vi.fn(),
|
checkSponsor: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../JobDetailsEditDrawer", () => ({
|
||||||
|
JobDetailsEditDrawer: ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onJobUpdated,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onJobUpdated: () => void | Promise<void>;
|
||||||
|
}) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="job-details-edit-drawer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onJobUpdated();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
toast: {
|
toast: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
@ -149,4 +174,28 @@ describe("DiscoveredPanel", () => {
|
|||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens edit details drawer from more actions", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const job = createJob();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DiscoveredPanel
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /edit details/i }));
|
||||||
|
expect(screen.getByTestId("job-details-edit-drawer")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
|
||||||
|
await waitFor(() => expect(onJobUpdated).toHaveBeenCalled());
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("job-details-edit-drawer"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { useRescoreJob } from "../../hooks/useRescoreJob";
|
import { useRescoreJob } from "../../hooks/useRescoreJob";
|
||||||
|
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
||||||
import { DecideMode } from "./DecideMode";
|
import { DecideMode } from "./DecideMode";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import { ProcessingState } from "./ProcessingState";
|
import { ProcessingState } from "./ProcessingState";
|
||||||
@ -27,6 +28,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
const [mode, setMode] = useState<PanelMode>("decide");
|
const [mode, setMode] = useState<PanelMode>("decide");
|
||||||
const [isSkipping, setIsSkipping] = useState(false);
|
const [isSkipping, setIsSkipping] = useState(false);
|
||||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||||
|
const [isEditDetailsOpen, setIsEditDetailsOpen] = useState(false);
|
||||||
const previousJobIdRef = useRef<string | null>(null);
|
const previousJobIdRef = useRef<string | null>(null);
|
||||||
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
setMode("decide");
|
setMode("decide");
|
||||||
setIsSkipping(false);
|
setIsSkipping(false);
|
||||||
setIsFinalizing(false);
|
setIsFinalizing(false);
|
||||||
|
setIsEditDetailsOpen(false);
|
||||||
onTailoringDirtyChange?.(false);
|
onTailoringDirtyChange?.(false);
|
||||||
}, [job?.id, onTailoringDirtyChange]);
|
}, [job?.id, onTailoringDirtyChange]);
|
||||||
|
|
||||||
@ -108,6 +111,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
isSkipping={isSkipping}
|
isSkipping={isSkipping}
|
||||||
onRescore={handleRescore}
|
onRescore={handleRescore}
|
||||||
isRescoring={isRescoring}
|
isRescoring={isRescoring}
|
||||||
|
onEditDetails={() => setIsEditDetailsOpen(true)}
|
||||||
onCheckSponsor={async () => {
|
onCheckSponsor={async () => {
|
||||||
await api.checkSponsor(job.id);
|
await api.checkSponsor(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
@ -122,6 +126,13 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
onDirtyChange={onTailoringDirtyChange}
|
onDirtyChange={onTailoringDirtyChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open={isEditDetailsOpen}
|
||||||
|
onOpenChange={setIsEditDetailsOpen}
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -80,6 +80,34 @@ vi.mock("../../components/TailoringEditor", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/JobDetailsEditDrawer", () => ({
|
||||||
|
JobDetailsEditDrawer: ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onJobUpdated,
|
||||||
|
job,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onJobUpdated: () => Promise<void>;
|
||||||
|
job: Job | null;
|
||||||
|
}) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="job-details-edit-drawer">
|
||||||
|
<div>{job?.id}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onJobUpdated();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/utils", async (importOriginal) => {
|
vi.mock("@/lib/utils", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@/lib/utils")>();
|
const actual = await importOriginal<typeof import("@/lib/utils")>();
|
||||||
return {
|
return {
|
||||||
@ -253,6 +281,28 @@ describe("JobDetailPanel", () => {
|
|||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens edit details drawer from menu and saves", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await renderJobDetailPanel({
|
||||||
|
activeTab: "all",
|
||||||
|
activeJobs: [],
|
||||||
|
selectedJob: createJob({ jobDescription: "Original" }),
|
||||||
|
onSelectJobId: vi.fn(),
|
||||||
|
onJobUpdated,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /edit details/i }));
|
||||||
|
expect(screen.getByTestId("job-details-edit-drawer")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onJobUpdated).toHaveBeenCalled());
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("job-details-edit-drawer"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("marks a job as applied from the action button", async () => {
|
it("marks a job as applied from the action button", async () => {
|
||||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
|
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
JobHeader,
|
JobHeader,
|
||||||
TailoredSummary,
|
TailoredSummary,
|
||||||
} from "../../components";
|
} from "../../components";
|
||||||
|
import { JobDetailsEditDrawer } from "../../components/JobDetailsEditDrawer";
|
||||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
@ -69,6 +70,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||||
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
||||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||||
|
const [isEditDetailsOpen, setIsEditDetailsOpen] = useState(false);
|
||||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||||
const previousSelectedJobIdRef = useRef<string | null>(null);
|
const previousSelectedJobIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@ -106,10 +108,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
if (!selectedJob) {
|
if (!selectedJob) {
|
||||||
setIsEditingDescription(false);
|
setIsEditingDescription(false);
|
||||||
setEditedDescription("");
|
setEditedDescription("");
|
||||||
|
setIsEditDetailsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsEditingDescription(false);
|
setIsEditingDescription(false);
|
||||||
setEditedDescription(selectedJob.jobDescription || "");
|
setEditedDescription(selectedJob.jobDescription || "");
|
||||||
|
setIsEditDetailsOpen(false);
|
||||||
}, [selectedJob?.id, selectedJob]);
|
}, [selectedJob?.id, selectedJob]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -432,6 +436,10 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
<Edit2 className="mr-2 h-4 w-4" />
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
Edit description
|
Edit description
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setIsEditDetailsOpen(true)}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
Edit details
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => void handleCopyInfo()}>
|
<DropdownMenuItem onSelect={() => void handleCopyInfo()}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Copy info
|
Copy info
|
||||||
@ -684,6 +692,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open={isEditDetailsOpen}
|
||||||
|
onOpenChange={setIsEditDetailsOpen}
|
||||||
|
job={selectedJob}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -132,6 +132,93 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates core job detail fields", async () => {
|
||||||
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
|
const job = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Original Title",
|
||||||
|
employer: "Original Employer",
|
||||||
|
jobUrl: "https://example.com/job/core-fields",
|
||||||
|
jobDescription: "Original description",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Updated Title",
|
||||||
|
employer: "Updated Employer",
|
||||||
|
jobUrl: "https://example.com/job/core-fields-updated",
|
||||||
|
applicationLink: "https://example.com/apply/core-fields-updated",
|
||||||
|
location: "London, UK",
|
||||||
|
salary: "GBP 100k",
|
||||||
|
deadline: "2026-03-31",
|
||||||
|
jobDescription: "Updated description",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.title).toBe("Updated Title");
|
||||||
|
expect(body.data.employer).toBe("Updated Employer");
|
||||||
|
expect(body.data.jobUrl).toBe(
|
||||||
|
"https://example.com/job/core-fields-updated",
|
||||||
|
);
|
||||||
|
expect(body.data.applicationLink).toBe(
|
||||||
|
"https://example.com/apply/core-fields-updated",
|
||||||
|
);
|
||||||
|
expect(body.data.location).toBe("London, UK");
|
||||||
|
expect(body.data.salary).toBe("GBP 100k");
|
||||||
|
expect(body.data.deadline).toBe("2026-03-31");
|
||||||
|
expect(body.data.jobDescription).toBe("Updated description");
|
||||||
|
expect(typeof body.meta.requestId).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when patching a missing job", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/missing-id`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Updated Title" }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(body.ok).toBe(false);
|
||||||
|
expect(body.error.code).toBe("NOT_FOUND");
|
||||||
|
expect(typeof body.meta.requestId).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 when patching to a duplicate job URL", async () => {
|
||||||
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
|
const first = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "First",
|
||||||
|
employer: "Acme",
|
||||||
|
jobUrl: "https://example.com/job/first",
|
||||||
|
jobDescription: "First description",
|
||||||
|
});
|
||||||
|
const second = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Second",
|
||||||
|
employer: "Acme",
|
||||||
|
jobUrl: "https://example.com/job/second",
|
||||||
|
jobDescription: "Second description",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${second.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ jobUrl: first.jobUrl }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(body.ok).toBe(false);
|
||||||
|
expect(body.error.code).toBe("CONFLICT");
|
||||||
|
expect(typeof body.meta.requestId).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
it("validates job updates and supports skip/delete flow", async () => {
|
it("validates job updates and supports skip/delete flow", async () => {
|
||||||
const { createJob } = await import("../../repositories/jobs");
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
const job = await createJob({
|
const job = await createJob({
|
||||||
@ -147,7 +234,33 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ suitabilityScore: 1000 }),
|
body: JSON.stringify({ suitabilityScore: 1000 }),
|
||||||
});
|
});
|
||||||
|
const badBody = await badRes.json();
|
||||||
expect(badRes.status).toBe(400);
|
expect(badRes.status).toBe(400);
|
||||||
|
expect(badBody.ok).toBe(false);
|
||||||
|
expect(badBody.error.code).toBe("INVALID_REQUEST");
|
||||||
|
expect(typeof badBody.meta.requestId).toBe("string");
|
||||||
|
|
||||||
|
const invalidCoreRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ employer: " " }),
|
||||||
|
});
|
||||||
|
const invalidCoreBody = await invalidCoreRes.json();
|
||||||
|
expect(invalidCoreRes.status).toBe(400);
|
||||||
|
expect(invalidCoreBody.ok).toBe(false);
|
||||||
|
expect(invalidCoreBody.error.code).toBe("INVALID_REQUEST");
|
||||||
|
expect(typeof invalidCoreBody.meta.requestId).toBe("string");
|
||||||
|
|
||||||
|
const patchRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ suitabilityScore: 77 }),
|
||||||
|
});
|
||||||
|
const patchBody = await patchRes.json();
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
expect(patchBody.ok).toBe(true);
|
||||||
|
expect(patchBody.data.suitabilityScore).toBe(77);
|
||||||
|
expect(typeof patchBody.meta.requestId).toBe("string");
|
||||||
|
|
||||||
const skipRes = await fetch(`${baseUrl}/api/jobs/${job.id}/skip`, {
|
const skipRes = await fetch(`${baseUrl}/api/jobs/${job.id}/skip`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { type Request, type Response, Router } from "express";
|
import { type Request, type Response, Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
|
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
|
||||||
import { AppError, badRequest } from "../../infra/errors";
|
import { AppError, badRequest, conflict } from "../../infra/errors";
|
||||||
import {
|
import {
|
||||||
generateFinalPdf,
|
generateFinalPdf,
|
||||||
processJob,
|
processJob,
|
||||||
@ -107,6 +107,13 @@ async function notifyJobCompleteWebhook(job: Job) {
|
|||||||
* PATCH /api/jobs/:id - Update a job
|
* PATCH /api/jobs/:id - Update a job
|
||||||
*/
|
*/
|
||||||
const updateJobSchema = z.object({
|
const updateJobSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(500).optional(),
|
||||||
|
employer: z.string().trim().min(1).max(500).optional(),
|
||||||
|
jobUrl: z.string().trim().min(1).max(2000).url().optional(),
|
||||||
|
applicationLink: z.string().trim().max(2000).url().nullable().optional(),
|
||||||
|
location: z.string().trim().max(200).nullable().optional(),
|
||||||
|
salary: z.string().trim().max(200).nullable().optional(),
|
||||||
|
deadline: z.string().trim().max(100).nullable().optional(),
|
||||||
status: z
|
status: z
|
||||||
.enum([
|
.enum([
|
||||||
"discovered",
|
"discovered",
|
||||||
@ -119,7 +126,7 @@ const updateJobSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
|
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
|
||||||
closedAt: z.number().int().nullable().optional(),
|
closedAt: z.number().int().nullable().optional(),
|
||||||
jobDescription: z.string().optional(),
|
jobDescription: z.string().trim().max(40000).nullable().optional(),
|
||||||
suitabilityScore: z.number().min(0).max(100).optional(),
|
suitabilityScore: z.number().min(0).max(100).optional(),
|
||||||
suitabilityReason: z.string().optional(),
|
suitabilityReason: z.string().optional(),
|
||||||
tailoredSummary: z.string().optional(),
|
tailoredSummary: z.string().optional(),
|
||||||
@ -158,6 +165,11 @@ const updateJobSchema = z.object({
|
|||||||
sponsorMatchNames: z.string().optional(),
|
sponsorMatchNames: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isJobUrlConflictError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
const transitionStageSchema = z.object({
|
const transitionStageSchema = z.object({
|
||||||
toStage: z.enum([...APPLICATION_STAGES, "no_change"]),
|
toStage: z.enum([...APPLICATION_STAGES, "no_change"]),
|
||||||
occurredAt: z.number().int().nullable().optional(),
|
occurredAt: z.number().int().nullable().optional(),
|
||||||
@ -649,16 +661,54 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => {
|
|||||||
const job = await jobsRepo.updateJob(req.params.id, input);
|
const job = await jobsRepo.updateJob(req.params.id, input);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return res.status(404).json({ success: false, error: "Job not found" });
|
const err = new AppError({
|
||||||
|
status: 404,
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Job not found",
|
||||||
|
});
|
||||||
|
logger.warn("Job update failed", {
|
||||||
|
route: "PATCH /api/jobs/:id",
|
||||||
|
jobId: req.params.id,
|
||||||
|
status: err.status,
|
||||||
|
code: err.code,
|
||||||
|
});
|
||||||
|
return fail(res, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: job });
|
logger.info("Job updated", {
|
||||||
|
route: "PATCH /api/jobs/:id",
|
||||||
|
jobId: req.params.id,
|
||||||
|
updatedFields: Object.keys(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
ok(res, job);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
const err =
|
||||||
return res.status(400).json({ success: false, error: error.message });
|
error instanceof z.ZodError
|
||||||
}
|
? badRequest(
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
error.issues[0]?.message ?? "Invalid job update request",
|
||||||
res.status(500).json({ success: false, error: message });
|
error.flatten(),
|
||||||
|
)
|
||||||
|
: isJobUrlConflictError(error)
|
||||||
|
? conflict("Another job already uses that job URL")
|
||||||
|
: error instanceof AppError
|
||||||
|
? error
|
||||||
|
: new AppError({
|
||||||
|
status: 500,
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error("Job update failed", {
|
||||||
|
route: "PATCH /api/jobs/:id",
|
||||||
|
jobId: req.params.id,
|
||||||
|
status: err.status,
|
||||||
|
code: err.code,
|
||||||
|
details: err.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
fail(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -295,10 +295,17 @@ export interface ManualJobFetchResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateJobInput {
|
export interface UpdateJobInput {
|
||||||
|
title?: string;
|
||||||
|
employer?: string;
|
||||||
|
jobUrl?: string;
|
||||||
|
applicationLink?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
salary?: string | null;
|
||||||
|
deadline?: string | null;
|
||||||
status?: JobStatus;
|
status?: JobStatus;
|
||||||
outcome?: JobOutcome | null;
|
outcome?: JobOutcome | null;
|
||||||
closedAt?: number | null;
|
closedAt?: number | null;
|
||||||
jobDescription?: string;
|
jobDescription?: string | null;
|
||||||
suitabilityScore?: number;
|
suitabilityScore?: number;
|
||||||
suitabilityReason?: string;
|
suitabilityReason?: string;
|
||||||
tailoredSummary?: string;
|
tailoredSummary?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user