diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx index 314ce6b..b491ae7 100644 --- a/job-tracker-ui/src/job-discovery.test.tsx +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -8,6 +8,8 @@ import JobDiscoveryPage from "./views/JobDiscoveryPage"; jest.mock("./api", () => ({ api: { get: jest.fn() } })); const mockedApi = api as jest.Mocked; +afterEach(() => jest.clearAllMocks()); + test("shows honest source metadata and offers the existing reviewed save flow", async () => { mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any); render(); @@ -22,3 +24,43 @@ test("shows honest source metadata and offers the existing reviewed save flow", await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/job-discovery/search", { params: { q: "backend", location: undefined } })); expect(screen.getByRole("button", { name: "Save to tracker" })).toBeInTheDocument(); }); + +test("distinguishes the initial guidance from a completed empty search", async () => { + mockedApi.get.mockResolvedValue({ data: [] } as any); + render(); + + expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); + + expect(await screen.findByText(/No active vacancies matched this search/)).toBeInTheDocument(); + expect(screen.queryByText("Search recent active vacancies by role, company, or municipality.")).not.toBeInTheDocument(); +}); + +test("retries the last submitted search after an error", async () => { + mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any); + render(); + + fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } }); + fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } }); + fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); + fireEvent.click(await screen.findByRole("button", { name: "Retry" })); + + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(2)); + expect(mockedApi.get).toHaveBeenLastCalledWith("/job-discovery/search", { params: { q: "utvikler", location: "Oslo" } }); +}); + +test("sorts returned vacancies by title", async () => { + mockedApi.get.mockResolvedValue({ data: [ + { id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" }, + { id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" }, + ] } as any); + render(); + fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); + await screen.findByText("2 vacancies found"); + + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Sort results" })); + fireEvent.click(await screen.findByRole("option", { name: "Title A–Z" })); + + const titles = screen.getAllByRole("heading", { level: 6 }).map((element) => element.textContent); + expect(titles).toEqual(["Analyst", "Zoologist"]); +}); diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx index 2b548f8..2b95715 100644 --- a/job-tracker-ui/src/views/JobDiscoveryPage.tsx +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -1,5 +1,5 @@ -import { FormEvent, useState } from "react"; -import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, Stack, TextField, Typography } from "@mui/material"; +import { FormEvent, useMemo, useState } from "react"; +import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, MenuItem, Stack, TextField, Typography } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import AddIcon from "@mui/icons-material/Add"; import { useNavigate } from "react-router-dom"; @@ -21,6 +21,7 @@ type DiscoveredJob = { }; const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString() : null; +type SortOrder = "updated" | "deadline" | "title"; export default function JobDiscoveryPage() { const navigate = useNavigate(); @@ -29,16 +30,34 @@ export default function JobDiscoveryPage() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [hasSearched, setHasSearched] = useState(false); + const [lastSearch, setLastSearch] = useState({ query: "", location: "" }); + const [sortOrder, setSortOrder] = useState("updated"); - const search = async (event: FormEvent) => { - event.preventDefault(); setLoading(true); setError(""); + const runSearch = async (nextQuery: string, nextLocation: string) => { + setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation }); try { - const response = await api.get("/job-discovery/search", { params: { q: query || undefined, location: location || undefined } }); + const response = await api.get("/job-discovery/search", { params: { q: nextQuery || undefined, location: nextLocation || undefined } }); setJobs(response.data ?? []); } catch { setError("NAV job discovery is temporarily unavailable."); } finally { setLoading(false); } }; + const search = (event: FormEvent) => { + event.preventDefault(); + void runSearch(query.trim(), location.trim()); + }; + + const sortedJobs = useMemo(() => [...jobs].sort((left, right) => { + if (sortOrder === "title") return left.title.localeCompare(right.title); + if (sortOrder === "deadline") { + const leftDeadline = left.deadline ? new Date(left.deadline).getTime() : Number.MAX_SAFE_INTEGER; + const rightDeadline = right.deadline ? new Date(right.deadline).getTime() : Number.MAX_SAFE_INTEGER; + return leftDeadline - rightDeadline; + } + return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime(); + }), [jobs, sortOrder]); + return ( @@ -47,15 +66,27 @@ export default function JobDiscoveryPage() { Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL. - {error ? {error} : null} - {!loading && jobs.length === 0 ? Search recent active vacancies by role, company, or municipality. : null} + {error ? void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>Retry}>{error} : null} + {!loading && !hasSearched ? Search recent active vacancies by role, company, or municipality. : null} + {!loading && hasSearched && !error && jobs.length === 0 ? No active vacancies matched this search. Try a broader role, company, or municipality. : null} + {loading ? Searching NAV vacancies… : null} + {jobs.length > 0 ? ( + + {jobs.length} {jobs.length === 1 ? "vacancy" : "vacancies"} found + setSortOrder(event.target.value as SortOrder)} sx={{ minWidth: 190 }}> + Recently updated + Deadline soonest + Title A–Z + + + ) : null} - {jobs.map((job) => ( - + {sortedJobs.map((job) => ( + {job.title} - {[job.company, job.location].filter(Boolean).join(" · ")} + {[job.company, job.location || "Location not provided"].filter(Boolean).join(" · ")} {job.acquisitionType === "searched" ? "Searched listing" : "Source type unavailable"} @@ -63,9 +94,10 @@ export default function JobDiscoveryPage() { {formatDate(job.modifiedAt) ? Listing updated {formatDate(job.modifiedAt)} : null} {formatDate(job.deadline) ? Application deadline: {formatDate(job.deadline)} : null} + Work arrangement not provided by this source - +