feat(jobs): integrate table and board workspace

This commit is contained in:
cesnimda
2026-09-29 23:01:05 +02:00
parent 6fc8937701
commit 384dfa8a4b
35 changed files with 1191 additions and 60 deletions
@@ -0,0 +1,124 @@
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<typeof api>;
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 <><output aria-label="Route">{location.pathname}{location.search}</output>
<button onClick={() => navigate(-1)}>History back</button><button onClick={() => navigate(1)}>History forward</button>
<button onClick={() => setRefreshToken(value => value + 1)}>Job created</button>
<JobTable refreshToken={refreshToken} pageSize={15} onPageSizeChange={jest.fn()} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: true }} onColumnsChange={jest.fn()} mode={mode} /></>;
}
function show(query = "", mode: "jobs" | "trash" = "jobs") {
return render(<MemoryRouter initialEntries={[`/${mode}${query}`]}><I18nProvider><ToastProvider><ConfirmProvider><PromptProvider><Consumer mode={mode} /></PromptProvider></ConfirmProvider></ToastProvider></I18nProvider></MemoryRouter>);
}
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" });
});