import React, { useState } from "react"; import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; import { api } from "./api"; import JobTable from "./components/JobTable"; import { ConfirmProvider } from "./confirm"; import { PromptProvider } from "./prompt"; import { ToastProvider } from "./toast"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { getUserScopedStorageKey } from "./auth"; const t = translations.en; jest.mock("./components/EditJobDialog", () => () => null); const mockedApi = api as jest.Mocked; const jobs = Array.from({ length: 27 }, (_, i) => ({ id: i + 1, jobTitle: i === 0 ? "Different" : `Engineer ${i}`, companyId: 1, company: { id: 1, name: "Nordlys" }, status: i === 1 ? "Custom review" : "Saved", location: "Oslo", isDeleted: false })); function Consumer({ mode = "jobs" }: { mode?: "jobs" | "trash" }) { const location = useLocation(); const navigate = useNavigate(); const [refreshToken, setRefreshToken] = useState(0); return <>{location.pathname}{location.search} ; } function show(query = "", mode: "jobs" | "trash" = "jobs") { return render(); } const reads = (path: string) => mockedApi.get.mock.calls.filter(([url]) => url === path); const ids = (selector: string, attribute: string) => Array.from(document.querySelectorAll(selector), el => Number(el.getAttribute(attribute))).sort((a, b) => a - b); beforeEach(() => { localStorage.clear(); jest.clearAllMocks(); mockedApi.get.mockImplementation((url, config) => { const p = (config?.params ?? {}) as { q?: string; status?: string; page?: number }; const matching = jobs.filter(job => (!p.q || job.jobTitle.includes(p.q)) && (!p.status || p.status === "All" || job.status === p.status)); return Promise.resolve({ data: url === "/companies" ? [jobs[0].company] : url === "/jobapplications/board" ? matching : url === "/jobapplications" ? { items: matching.slice(((p.page ?? 1) - 1) * 15, (p.page ?? 1) * 15), total: matching.length, page: p.page, pageSize: 15 } : [] } as any); }); }); test("Table/Board switches preserve query, complete membership, page, density and history without hidden reads", async () => { show("?q=Engineer&page=2&sortBy=jobTitle&sortDir=asc"); await screen.findByText("16–26 of 26"); expect(reads("/jobapplications/board")).toHaveLength(0); const pageIds = ids("[data-job-row-id]", "data-job-row-id"); fireEvent.click(screen.getAllByRole("checkbox", { name: /Select Engineer/ })[0]); fireEvent.mouseDown(screen.getByRole("combobox", { name: t.listDensity })); fireEvent.click(await screen.findByRole("option", { name: "Compact" })); const count = reads("/jobapplications").length; const board = screen.getByRole("link", { name: "Board" }); board.focus(); fireEvent.click(board); await waitFor(() => expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(jobs.slice(1).map(j => j.id))); expect(board).toHaveFocus(); expect(board).toHaveAttribute("aria-current", "page"); expect(reads("/jobapplications")).toHaveLength(count); expect(reads("/jobapplications/board")).toHaveLength(1); expect(screen.queryByRole("combobox", { name: t.listDensity })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("link", { name: "Table" })); await screen.findByText("16–26 of 26"); expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual(pageIds); expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent("Compact"); expect(screen.getAllByRole("checkbox", { name: /Select Engineer/ }).every(el => !(el as HTMLInputElement).checked)).toBe(true); expect(reads("/jobapplications/board")).toHaveLength(1); fireEvent.click(screen.getByText("History back")); await waitFor(() => expect(document.querySelectorAll("[data-kanban-card-id]")).toHaveLength(26)); expect(screen.getByLabelText("Route")).toHaveTextContent("view=board"); fireEvent.click(screen.getByText("History forward")); await screen.findByText("16–26 of 26"); expect(screen.getByLabelText("Route")).toHaveTextContent("view=table"); }); test.each(["?q=Engineer&status=Custom+review", "?q=absent"])("direct Board and both switch directions retain exact result membership: %s", async query => { show(`${query}&view=board`); const expected = query.includes("absent") ? [] : [2]; await screen.findByText(`${expected.length} matching jobs`); expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(expected); expect(reads("/jobapplications")).toHaveLength(0); fireEvent.click(screen.getByRole("link", { name: "Table" })); await waitFor(() => expect(reads("/jobapplications")).toHaveLength(1)); await waitFor(() => expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual(expected)); fireEvent.click(screen.getByRole("link", { name: "Board" })); await screen.findByText(`${expected.length} matching jobs`); expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(expected); expect(reads("/jobapplications/board").every(([, config]) => (config?.params as { q?: string }).q === (expected.length ? "Engineer" : "absent"))).toBe(true); }); test("Board uses the shared filter controls and saved query owner without fetching Table", async () => { show("?view=board"); await screen.findByText("27 matching jobs"); fireEvent.change(screen.getByRole("textbox", { name: t.jobTableSearch }), { target: { value: "Engineer" } }); await screen.findByText("26 matching jobs"); fireEvent.mouseDown(screen.getByRole("combobox", { name: "Status" })); fireEvent.click(await screen.findByRole("option", { name: "Offer" })); await screen.findByText("0 matching jobs"); expect(reads("/jobapplications")).toHaveLength(0); expect(screen.getByLabelText("Route")).toHaveTextContent("view=board&q=Engineer&status=Offer"); }); test("Trash stays a Table even when supplied a Board view parameter", async () => { show("?view=board", "trash"); await screen.findByText("1–15 of 27"); expect(screen.queryByRole("group", { name: "Jobs view" })).not.toBeInTheDocument(); expect(reads("/jobapplications/board")).toHaveLength(0); expect(reads("/jobapplications")[0][1]?.params).toMatchObject({ deletedOnly: true }); expect(within(screen.getByRole("table")).getAllByRole("row")).toHaveLength(16); }); test("saved filters applied in Board reset the Table page and refresh only the active population", async () => { localStorage.setItem(getUserScopedStorageKey("jt_saved_views_v1"), JSON.stringify([{ id: "review", name: "Review shortlist", params: { q: "Engineer", status: "Custom review" }, createdAt: "2026-09-29" }])); show("?view=board&page=2"); await screen.findByText("27 matching jobs"); fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip })); fireEvent.click(await screen.findByText("Review shortlist")); await screen.findByText("1 matching jobs"); expect(screen.getByLabelText("Route")).not.toHaveTextContent("page="); expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual([2]); const before = reads("/jobapplications/board").length; fireEvent.click(screen.getByRole("button", { name: "Job created" })); await waitFor(() => expect(reads("/jobapplications/board")).toHaveLength(before + 1)); expect(reads("/jobapplications")).toHaveLength(0); fireEvent.click(screen.getByRole("link", { name: "Table" })); await waitFor(() => expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual([2])); expect(reads("/jobapplications")[0][1]?.params).toMatchObject({ page: 1, q: "Engineer", status: "Custom review" }); });