Files
jobtrackingapp/job-tracker-ui/src/job-table-density.test.tsx
T

195 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { api } from "./api";
import JobTable, { type JobTableColumns } 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, setAuthUserKey } from "./auth";
jest.mock("./components/EditJobDialog", () => ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? <div role="dialog" aria-label="Edit job"><button onClick={onClose}>Close editor</button></div> : null);
const mockedApi = api as jest.Mocked<typeof api>;
const job = { id: 42, companyId: 1, company: { id: 1, name: "Østlandet" }, jobTitle: "Platform engineer", status: "Custom review", dateApplied: "2026-08-01", daysSince: 2, location: "Tromsø", jobUrl: "https://example.test/listing", isDeleted: false };
const t = translations.en;
function Consumer({ mode }: { mode: "jobs" | "trash" }) {
const location = useLocation();
const [pageSize, setPageSize] = useState<15 | 20 | 25>(15);
const [columns, setColumns] = useState<JobTableColumns>({ status: true, dateApplied: true, daysSince: true, jobUrl: true });
return <><output aria-label="Current route">{location.pathname}{location.search}</output><JobTable mode={mode} refreshToken={0} pageSize={pageSize} onPageSizeChange={setPageSize} columns={columns} onColumnsChange={setColumns} /></>;
}
function show(mode: "jobs" | "trash" = "jobs", query = "") {
return render(<MemoryRouter initialEntries={[`/${mode}${query}`]}><I18nProvider><ToastProvider><ConfirmProvider><PromptProvider><Consumer mode={mode} /></PromptProvider></ConfirmProvider></ToastProvider></I18nProvider></MemoryRouter>);
}
const calls = () => mockedApi.get.mock.calls.filter(([url]) => url === "/jobapplications");
const lastParams = () => calls().at(-1)?.[1]?.params as Record<string, unknown>;
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job], total: 31, page: 1, pageSize: 15 } : [] } as any));
});
test("row status/edit/delete controls retain selection and do not open the workspace", async () => {
show();
const row = await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(within(row).getByRole("checkbox"));
fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableEdit}: ${job.jobTitle}` }));
expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible();
fireEvent.click(screen.getByText("Close editor"));
expect(within(row).getByRole("checkbox")).toBeChecked();
fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableSoftDelete}: ${job.jobTitle}` }));
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: /cancel/i }));
expect(mockedApi.delete).not.toHaveBeenCalled();
fireEvent.click(await within(row).findByRole("button", { name: `${t.jobTableQuickStatus}: ${job.jobTitle}` }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Interview/ }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Interview" }));
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs$/);
});
test("table sort, page-size and optional columns keep their existing independent contracts", async () => {
show("jobs", "?q=platform&location=Troms%C3%B8");
await screen.findByRole("row", { name: /open platform engineer/i });
fireEvent.click(screen.getByRole("button", { name: t.jobTableCompany }));
await waitFor(() => expect(lastParams()).toMatchObject({ q: "platform", location: "Tromsø", sortBy: "company", sortDir: "asc" }));
fireEvent.click(screen.getByRole("button", { name: "Go to next page" }));
await waitFor(() => expect(lastParams().page).toBe(2));
fireEvent.mouseDown(screen.getByRole("combobox", { name: /rows per page/i }));
fireEvent.click(await screen.findByRole("option", { name: "20" }));
await waitFor(() => expect(lastParams()).toMatchObject({ page: 1, pageSize: 20 }));
fireEvent.mouseDown(screen.getByRole("combobox", { name: /rows per page/i }));
fireEvent.click(await screen.findByRole("option", { name: "25" }));
await waitFor(() => expect(lastParams()).toMatchObject({ page: 1, pageSize: 25 }));
fireEvent.click(screen.getByRole("button", { name: t.jobTableColumns }));
fireEvent.click(await screen.findByRole("menuitem", { name: t.settingsColumnJobUrl }));
expect(screen.queryByRole("link", { name: t.jobTableLink })).not.toBeInTheDocument();
});
test("Trash select-all and bulk restore use the original IDs without workspace navigation", async () => {
show("trash");
await screen.findByText(job.jobTitle);
fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll }));
fireEvent.click(screen.getByRole("button", { name: t.jobTableRestoreSelected }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/42/restore"));
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/trash$/);
});
async function compact() {
fireEvent.mouseDown(screen.getByRole("combobox", { name: t.listDensity }));
fireEvent.click(await screen.findByRole("option", { name: t.densityCompact }));
}
test("density preserves selected rows, query, page, custom status, columns and listing link without refetch", async () => {
show("jobs", "?q=platform&page=2&sortBy=company&sortDir=asc");
const row = await screen.findByRole("row", { name: /open platform engineer/i });
const columns = within(row).getAllByRole("cell").length;
fireEvent.click(within(row).getByRole("checkbox"));
const before = calls().length;
expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityComfortable);
await compact();
expect(within(row).getByRole("checkbox")).toBeChecked();
expect(within(row).getAllByRole("cell")).toHaveLength(columns);
expect(within(row).getByText("Custom review")).toBeVisible();
expect(within(row).getByRole("link", { name: t.jobTableLink })).toHaveAttribute("href", job.jobUrl);
expect(screen.getByText("16–30 of 31")).toBeVisible();
expect(screen.getByLabelText("Current route")).toHaveTextContent("/jobs?q=platform&page=2&sortBy=company&sortDir=asc");
expect(calls()).toHaveLength(before);
fireEvent.click(screen.getByRole("button", { name: "Offer" }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" }));
});
test("density is local and resets on unmount without writing settings or a saved view", async () => {
const view = show();
await screen.findByText(job.jobTitle);
const stored = { ...localStorage };
await compact();
expect({ ...localStorage }).toEqual(stored);
expect(mockedApi.put).not.toHaveBeenCalled();
view.unmount();
show();
await screen.findByText(job.jobTitle);
expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityComfortable);
});
test("density survives an empty result and a failed refresh with the existing retry action", async () => {
show();
await screen.findByText(job.jobTitle);
await compact();
mockedApi.get.mockImplementation((url: string) => url === "/jobapplications" ? Promise.reject({ message: "Synthetic unavailable", response: { status: 503 } }) : Promise.resolve({ data: [] } as any));
fireEvent.click(screen.getByRole("button", { name: t.tableNextPage }));
expect(await screen.findByRole("alert")).toHaveTextContent("Synthetic unavailable");
expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityCompact);
mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 15 } } as any);
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityCompact);
expect(screen.queryByText(job.jobTitle)).not.toBeInTheDocument();
});
test("Bokmål display localizes standard statuses without changing API values or custom status filters", async () => {
localStorage.setItem("uiLanguage", "nb");
const standardJob = { ...job, status: "Interview", dateApplied: "2026-08-01", deadline: "2026-08-10" };
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [standardJob], total: 1, page: 1, pageSize: 15 } : [] } as any));
show("jobs", "?status=Custom%20review");
const row = await screen.findByRole("row", { name: /platform engineer/i });
expect(lastParams().status).toBe("Custom review");
expect(within(row).getByText(translations.nb.statusInterview)).toBeVisible();
expect(within(row).getByText(new Date(standardJob.dateApplied).toLocaleDateString("nb-NO"))).toBeVisible();
expect(within(row).getByText(new Date(standardJob.deadline).toLocaleDateString("nb-NO"))).toBeVisible();
expect(screen.getByRole("combobox", { name: translations.nb.jobTableStatus })).toHaveTextContent("Custom review");
fireEvent.click(within(row).getByRole("button", { name: `${translations.nb.jobTableQuickStatus}: ${job.jobTitle}` }));
fireEvent.click(await screen.findByRole("menuitem", { name: translations.nb.jobTableSetStatus.replace("{status}", translations.nb.statusOffer) }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" }));
});
test("partial bulk failure refreshes successful rows and retains only failed visible IDs", async () => {
const other = { ...job, id: 43, jobTitle: "Second role" };
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job, other], total: 2, page: 1, pageSize: 15 } : [] } as any));
mockedApi.patch.mockImplementation((url: string) => url.includes("/43/") ? Promise.reject(new Error("Synthetic failure")) : Promise.resolve({ status: 204 } as any));
show();
const first = await screen.findByRole("row", { name: /open platform engineer/i });
const second = screen.getByRole("row", { name: /open second role/i });
fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll }));
fireEvent.click(screen.getByRole("button", { name: "Offer" }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2));
await waitFor(() => expect(within(first).getByRole("checkbox")).not.toBeChecked());
expect(within(second).getByRole("checkbox")).toBeChecked();
expect(calls().length).toBeGreaterThan(1);
});
test("saved view create/apply/delete stays scoped to the signed-in account", async () => {
setAuthUserKey("account-a");
show("jobs", "?q=platform&status=Interview");
await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip }));
fireEvent.change(screen.getByRole("textbox", { name: t.savedViewsName }), { target: { value: "Interview search" } });
fireEvent.click(screen.getByRole("button", { name: t.savedViewsSaveCurrent }));
expect(localStorage.getItem(getUserScopedStorageKey("jt_saved_views_v1"))).toContain("Interview search");
fireEvent.click(screen.getByRole("menuitem", { name: /Interview search/ }));
expect(screen.getByLabelText("Current route")).toHaveTextContent("status=Interview");
act(() => setAuthUserKey("account-b"));
fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip }));
expect(screen.getByText(t.savedViewsEmpty)).toBeVisible();
act(() => setAuthUserKey("account-a"));
await waitFor(() => expect(screen.queryByText(t.savedViewsEmpty)).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip }));
expect(screen.getByRole("menuitem", { name: /Interview search/ })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: `${t.attachmentsDelete}: Interview search` }));
expect(localStorage.getItem(getUserScopedStorageKey("jt_saved_views_v1"))).not.toContain("Interview search");
});
test("changing the result set clears selection before an old row can be bulk-updated", async () => {
show();
const row = await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(within(row).getByRole("checkbox"));
expect(screen.getByText(t.jobTableSelected.replace("{count}", "1"))).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: t.jobTableSearch }), { target: { value: "different" } });
expect(within(row).getByRole("checkbox")).not.toBeChecked();
expect(screen.queryByRole("button", { name: "Offer" })).not.toBeInTheDocument();
expect(mockedApi.patch).not.toHaveBeenCalled();
});