143 lines
10 KiB
TypeScript
143 lines
10 KiB
TypeScript
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, useI18n } 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);
|
||
function LanguageSwitch() {
|
||
const { language, setLocalLanguage } = useI18n();
|
||
return <button onClick={() => setLocalLanguage(language === 'en' ? 'nb' : 'en')}>Change language</button>;
|
||
}
|
||
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.each((['en', 'nb'] as const).flatMap(language => ['table', 'board'].flatMap(view => [0, 1, 2].map(count => [language, view, count] as const))))('localised matching count: %s %s %s retains membership when language changes', async (language, view, count) => {
|
||
localStorage.setItem('uiLanguage', language);
|
||
mockedApi.get.mockImplementation(url => Promise.resolve({ data: url === '/companies' ? [jobs[0].company] : url === '/jobapplications/board' ? jobs.slice(0, count) : { items: jobs.slice(0, count), total: count, page: 1, pageSize: 15 } } as any));
|
||
render(<MemoryRouter initialEntries={[`/jobs?view=${view}`]}><I18nProvider><LanguageSwitch /><ToastProvider><ConfirmProvider><PromptProvider><Consumer /></PromptProvider></ConfirmProvider></ToastProvider></I18nProvider></MemoryRouter>);
|
||
const label = (locale: 'en' | 'nb') => locale === 'en' ? `${count} matching ${count === 1 ? 'job' : 'jobs'}` : `${count} ${count === 1 ? 'jobb' : 'jobber'} samsvarer`;
|
||
await screen.findByText(label(language));
|
||
fireEvent.click(screen.getByRole('button', { name: 'Change language' }));
|
||
await screen.findByText(label(language === 'en' ? 'nb' : 'en'));
|
||
expect(reads(view === 'board' ? '/jobapplications' : '/jobapplications/board')).toHaveLength(0);
|
||
expect(ids(view === 'board' ? '[data-kanban-card-id]' : '[data-job-row-id]', view === 'board' ? 'data-kanban-card-id' : 'data-job-row-id')).toEqual(jobs.slice(0, count).map(job => job.id));
|
||
});
|
||
|
||
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.getByRole("button", { name: t.jobTableSelectionMode }));
|
||
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.queryByRole("checkbox", { name: /Select Engineer/ })).not.toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: t.jobTableSelectionMode })).toHaveAttribute("aria-pressed", "false");
|
||
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 === 1 ? "1 matching job" : `${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 === 1 ? "1 matching job" : `${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 job");
|
||
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" });
|
||
});
|