feat(ui): establish table and list density contract
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test as base, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import { translations } from "../src/i18n/translations";
|
||||
|
||||
const apiUrl = "http://localhost:5302/api";
|
||||
const password = "E2ePassword123!";
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
const csrf = (await api.storageState()).cookies.find(c => c.name === "XSRF-TOKEN")!.value;
|
||||
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
|
||||
}
|
||||
const test = base.extend<{ owner: { headers: Record<string, string>; ids: number[] } }>({
|
||||
owner: async ({ playwright, page, context }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, "e2e@example.test");
|
||||
const email = `p405-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
await context.route("**/*", route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS", location: "Tromsø" } });
|
||||
expect(company.status()).toBe(201);
|
||||
const companyId = (await company.json()).id;
|
||||
const ids: number[] = [];
|
||||
for (let index = 0; index < 18; index++) {
|
||||
const result = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: {
|
||||
companyId, jobTitle: `Density ${String(index).padStart(2, "0")}${index === 0 ? " — Seniorrådgiver for plattformutvikling og digitale fellestjenester" : " Engineer"}`,
|
||||
status: index === 0 ? "Avventer faglig vurdering" : index % 3 === 0 ? "Interview" : "Saved",
|
||||
dateApplied: "2026-08-01", location: "Tromsø / hjemmekontor", jobUrl: "https://example.test/listing",
|
||||
} });
|
||||
expect(result.status()).toBe(201);
|
||||
ids.push((await result.json()).id);
|
||||
}
|
||||
await provide({ headers, ids });
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const path = process.env.P4_05_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-05/visual/${name}.png` : info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path, fullPage: false });
|
||||
await info.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
async function scan(page: Page, info: TestInfo, name: string) {
|
||||
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
||||
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: "application/json" });
|
||||
expect(result.violations).toEqual([]);
|
||||
}
|
||||
async function keyboardFocus(page: Page, target: Locator) {
|
||||
await target.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(target).toBeFocused();
|
||||
const ring = await target.evaluate(node => {
|
||||
const control = node.closest(".MuiInputBase-root, .MuiCheckbox-root") ?? node;
|
||||
const notch = control.querySelector(".MuiOutlinedInput-notchedOutline");
|
||||
const css = getComputedStyle(notch ?? control);
|
||||
return { width: notch ? css.borderTopWidth : css.outlineWidth, style: notch ? css.borderTopStyle : css.outlineStyle };
|
||||
});
|
||||
expect(ring).toEqual({ width: "3px", style: "solid" });
|
||||
}
|
||||
|
||||
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
|
||||
test(`[PRES-J05 J06 W01 W02 S03 E08 E09] ${mode}/${language} table and cards preserve behavior at both densities`, async ({ page, owner }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const suffix = `${mode}-${language}`;
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204);
|
||||
await page.addInitScript(({ mode, language }) => {
|
||||
localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language);
|
||||
localStorage.setItem("jobColumns", JSON.stringify({ status: true, dateApplied: true, daysSince: true, jobUrl: true }));
|
||||
localStorage.setItem("jobPageSize", "15");
|
||||
}, { mode, language });
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/jobs?q=Density&sortBy=jobTitle&sortDir=asc");
|
||||
const main = page.getByRole("main");
|
||||
const density = main.getByRole("combobox", { name: t.listDensity, exact: true });
|
||||
const first = main.locator(`[data-job-row-id='${owner.ids[0]}']`);
|
||||
const ordinary = main.locator(`[data-job-row-id='${owner.ids[1]}']`);
|
||||
const region = main.getByRole("region", { name: t.jobTableScrollRegion });
|
||||
await expect(first).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
|
||||
await expect(density).toHaveText(t.densityComfortable);
|
||||
await expect(main.getByRole("columnheader", { name: t.jobTableRole, exact: true })).toHaveAttribute("aria-sort", "ascending");
|
||||
const rowText = await first.innerText();
|
||||
const headers = await main.getByRole("columnheader").allTextContents();
|
||||
const select = first.getByRole("checkbox");
|
||||
await select.check();
|
||||
const query = new URL(page.url()).search;
|
||||
const rowHeights: number[] = [];
|
||||
let listingReads = 0;
|
||||
page.on("request", request => { if (new URL(request.url()).pathname === "/api/jobapplications" && request.method() === "GET") listingReads++; });
|
||||
const chooseDensity = async (value: string) => {
|
||||
await density.focus(); await density.press("Enter");
|
||||
await page.getByRole("option", { name: value, exact: true }).focus(); await page.keyboard.press("Enter");
|
||||
await expect(density).toBeFocused();
|
||||
};
|
||||
for (const value of ["comfortable", "compact"] as const) {
|
||||
if (value === "compact") await chooseDensity(t.densityCompact);
|
||||
await expect(select).toBeChecked();
|
||||
expect(await first.innerText()).toBe(rowText);
|
||||
expect(await main.getByRole("columnheader").allTextContents()).toEqual(headers);
|
||||
expect(new URL(page.url()).search).toBe(query);
|
||||
expect(listingReads).toBe(0);
|
||||
rowHeights.push((await ordinary.boundingBox())!.height);
|
||||
await keyboardFocus(page, select);
|
||||
await keyboardFocus(page, region);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(() => region.evaluate(node => node.scrollLeft)).toBeGreaterThan(0);
|
||||
await region.evaluate(node => { node.scrollLeft = 0; });
|
||||
await density.scrollIntoViewIfNeeded();
|
||||
await capture(page, info, `desktop-${value}-${suffix}`);
|
||||
await scan(page, info, `desktop-${value}-${suffix}`);
|
||||
}
|
||||
expect(rowHeights[0] - rowHeights[1]).toBeCloseTo(8, 0);
|
||||
await info.attach(`row-heights-${suffix}`, { body: JSON.stringify({ comfortable: rowHeights[0], compact: rowHeights[1] }), contentType: "application/json" });
|
||||
|
||||
// Responsive cards retain selected IDs; targets do not shrink with density.
|
||||
for (const value of ["comfortable", "compact"] as const) {
|
||||
await chooseDensity(value === "compact" ? t.densityCompact : t.densityComfortable);
|
||||
for (const width of [1024, 768, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 960 });
|
||||
await expect(first.getByRole("checkbox")).toBeChecked();
|
||||
await expect(first.getByText("Avventer faglig vurdering", { exact: true })).toBeVisible();
|
||||
if (width >= 768) { await expect(main.getByRole("table")).toBeVisible(); continue; }
|
||||
await expect(main.getByRole("table")).toHaveCount(0);
|
||||
// Geometric containment alone misses a title squeezed into one-letter lines.
|
||||
expect((await first.getByText(/^Density 00/).boundingBox())!.width).toBeGreaterThanOrEqual(150);
|
||||
const metrics = await first.evaluate(card => Array.from(card.querySelectorAll("button,a,.MuiCheckbox-root")).map(node => {
|
||||
const rect = node.getBoundingClientRect(); return { text: node.textContent, width: rect.width, height: rect.height, left: rect.left, right: rect.right };
|
||||
}));
|
||||
expect(metrics.every(rect => rect.height >= 44 && rect.width >= 44 && rect.left >= 0 && rect.right <= width)).toBe(true);
|
||||
const overflow = await first.evaluate(card => Array.from(card.querySelectorAll("p,span,button,a")).filter(node => node.getClientRects().length).map(node => node.getBoundingClientRect()).some(rect => rect.left < 0 || rect.right > innerWidth + 1));
|
||||
expect(overflow).toBe(false);
|
||||
await first.scrollIntoViewIfNeeded();
|
||||
await capture(page, info, `phone-${width}-${value}-${suffix}`);
|
||||
if (width === 390) await scan(page, info, `phone-${value}-${suffix}`);
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
}
|
||||
|
||||
// Native row status request, listing-link isolation and workspace navigation.
|
||||
await select.uncheck();
|
||||
// Match by the translated action prefix; the long title remains in its accessible name.
|
||||
const statusAction = first.getByRole("button", { name: new RegExp(`^${t.jobTableQuickStatus}:`) });
|
||||
await keyboardFocus(page, statusAction);
|
||||
await page.keyboard.press("Escape"); // Dismiss the existing tooltip, preserving focus.
|
||||
const targets = await first.locator(".MuiIconButton-root,.MuiCheckbox-root").evaluateAll(nodes => nodes.map(node => ({ width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height })));
|
||||
expect(targets.every(target => target.width >= 32 && target.height >= 32)).toBe(true);
|
||||
await capture(page, info, `row-action-focus-${suffix}`);
|
||||
await statusAction.press("Enter");
|
||||
const changed = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/status` && r.request().method() === "PATCH");
|
||||
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", "Interview"), exact: true }).click();
|
||||
expect((await changed).status()).toBe(204);
|
||||
await expect(first.getByText(t.statusInterview, { exact: true })).toBeVisible();
|
||||
expect(new URL(page.url()).pathname).toBe("/jobs");
|
||||
const listing = first.getByRole("link", { name: t.jobTableLink, exact: true });
|
||||
const popup = page.waitForEvent("popup");
|
||||
await listing.click();
|
||||
await (await popup).close();
|
||||
expect(new URL(page.url()).pathname).toBe("/jobs");
|
||||
await first.focus(); await first.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[0]}$`));
|
||||
await page.goBack();
|
||||
// Existing App keeps the list mounted behind the dedicated workspace.
|
||||
await expect(density).toHaveText(t.densityCompact);
|
||||
await expect(first).toBeFocused();
|
||||
|
||||
await main.getByRole("button", { name: t.tableNextPage }).click();
|
||||
await expect(main.locator("[data-job-row-id]")).toHaveCount(3);
|
||||
await chooseDensity(t.densityCompact);
|
||||
expect(new URL(page.url()).searchParams.get("page")).toBe("2");
|
||||
await main.getByRole("combobox", { name: t.settingsRowsPerPage }).click();
|
||||
await page.getByRole("option", { name: "20", exact: true }).click();
|
||||
await expect(main.locator("[data-job-row-id]")).toHaveCount(18);
|
||||
await expect(density).toHaveText(t.densityCompact);
|
||||
expect(await page.evaluate(() => localStorage.getItem("jobPageSize"))).toBe("20");
|
||||
|
||||
await main.getByRole("button", { name: t.jobTableColumns, exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: t.settingsColumnJobUrl, exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(first.getByRole("link", { name: t.jobTableLink, exact: true })).toHaveCount(0);
|
||||
expect(JSON.parse((await page.evaluate(() => localStorage.getItem("jobColumns")))!).jobUrl).toBe(false);
|
||||
|
||||
// Real confirm/soft-delete and the same renderer's existing Trash restore path.
|
||||
await first.getByRole("button", { name: new RegExp(`^${t.jobTableSoftDelete}:`) }).click();
|
||||
const deleted = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}` && r.request().method() === "DELETE");
|
||||
await page.getByRole("dialog").getByRole("button", { name: t.jobTableMove, exact: true }).click();
|
||||
expect((await deleted).status()).toBe(204);
|
||||
await expect(first).toHaveCount(0);
|
||||
await page.goto("/trash");
|
||||
await expect(first).toBeVisible();
|
||||
await chooseDensity(t.densityCompact);
|
||||
await first.getByRole("checkbox").check();
|
||||
const restored = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/restore` && r.request().method() === "POST");
|
||||
await main.getByRole("button", { name: t.jobTableRestoreSelected, exact: true }).click();
|
||||
expect((await restored).status()).toBe(204);
|
||||
await expect(first).toHaveCount(0);
|
||||
await expect(density).toHaveText(t.densityCompact);
|
||||
await scan(page, info, `empty-trash-${suffix}`);
|
||||
|
||||
// Only this GET is held/failed; loaded data, actions and all mutations above
|
||||
// use the real owner API. Retry keeps the existing error contract.
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`${apiUrl}/jobapplications?*`, async route => { await held; await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic list unavailable" }) }); });
|
||||
await page.goto("/jobs?q=Density");
|
||||
await expect(main.getByText(t.loading, { exact: true })).toBeVisible();
|
||||
await chooseDensity(t.densityCompact);
|
||||
release();
|
||||
await expect(main.getByRole("alert")).toBeVisible();
|
||||
await capture(page, info, `list-error-${suffix}`);
|
||||
await scan(page, info, `list-error-${suffix}`);
|
||||
await page.unroute(`${apiUrl}/jobapplications?*`);
|
||||
await main.getByRole("button", { name: t.retry, exact: true }).click();
|
||||
await expect(first).toBeVisible();
|
||||
await expect(main.getByRole("alert")).toHaveCount(0);
|
||||
await expect(density).toHaveText(t.densityCompact);
|
||||
});
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import type { ContentDensity } from "../theme";
|
||||
|
||||
interface PagedResult<T> {
|
||||
items: T[];
|
||||
@@ -179,6 +180,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(() => queryPage(location.search));
|
||||
// Presentation only: not an account preference, saved-view field or API input.
|
||||
const [density, setDensity] = useState<ContentDensity>("comfortable");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [search, setSearch] = useState(() => new URLSearchParams(location.search).get("q") ?? "");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
@@ -456,6 +459,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const visibleDesktopColumns = 6 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl);
|
||||
const selectedCompanyIsLoading = companyFilterId !== "All" && !companies.some((company) => company.id === companyFilterId);
|
||||
const densityControl = (
|
||||
<FormControl size="small" sx={{ minWidth: 150, width: { xs: "100%", sm: "auto" } }}>
|
||||
<InputLabel id="job-table-density-label">{t("listDensity")}</InputLabel>
|
||||
<Select labelId="job-table-density-label" label={t("listDensity")} value={density} onChange={(event) => setDensity(event.target.value as ContentDensity)}>
|
||||
<MenuItem value="comfortable">{t("densityComfortable")}</MenuItem>
|
||||
<MenuItem value="compact">{t("densityCompact")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -537,6 +549,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{t("jobTableColumns")}
|
||||
</Button>
|
||||
</Box>
|
||||
{densityControl}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
@@ -589,6 +602,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined, readiness: readinessFilter === "all" ? undefined : readinessFilter }} onApply={applySavedView} />
|
||||
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
|
||||
{densityControl}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -605,8 +619,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
|
||||
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
|
||||
{([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
|
||||
<Checkbox checked={columns[key]} />
|
||||
<MenuItem key={key} aria-label={label} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
|
||||
<Checkbox checked={columns[key]} inputProps={{ "aria-label": label }} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -629,16 +643,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
) : null}
|
||||
|
||||
<Paper
|
||||
data-content-density={density}
|
||||
sx={{
|
||||
mt: 2,
|
||||
overflow: "hidden",
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
boxShadow: "var(--customShadows-section, none)",
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.25} sx={{ p: 1.25 }}>
|
||||
<Stack spacing="var(--app-list-gap)" sx={{ p: 1.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
|
||||
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
|
||||
</Box>
|
||||
@@ -652,7 +667,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
data-job-row-id={job.id}
|
||||
role={mode === "jobs" && !job.isDeleted ? "link" : undefined}
|
||||
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? t("jobTableOpenJob", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }) : undefined}
|
||||
onClick={(event) => {
|
||||
if (mode === "jobs" && !job.isDeleted && !isInteractiveTarget(event.target)) openJob(job.id);
|
||||
}}
|
||||
@@ -663,20 +678,20 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
p: "var(--app-list-item-padding)",
|
||||
borderRadius: 3.5,
|
||||
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.03),
|
||||
backgroundColor: isSelected ? "action.selected" : alpha(theme.palette.primary.main, 0.03),
|
||||
borderColor: alpha(theme.palette.primary.main, 0.08),
|
||||
boxShadow: `0 10px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.18 : 0.06)}`,
|
||||
"&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined,
|
||||
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Stack spacing="var(--app-list-gap)">
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: "1 1 180px" }}>
|
||||
<Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{job.company?.name ?? t("jobTableCompany")}
|
||||
@@ -686,7 +701,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
||||
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
@@ -767,8 +782,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Table size="small" sx={{ minWidth: 980 }}>
|
||||
<Box role="region" aria-label={t("jobTableScrollRegion")} tabIndex={0} sx={{ overflowX: "auto", "&:focus-visible": { outlineOffset: -3 } }}>
|
||||
<Table aria-label={t(mode === "trash" ? "trash" : "jobApplications")} size="small" sx={{ minWidth: 980 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectAll") }} checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
@@ -792,8 +807,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
key={job.id}
|
||||
data-job-row-id={job.id}
|
||||
hover={mode === "jobs" && !job.isDeleted}
|
||||
selected={isSelected}
|
||||
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? t("jobTableOpenJob", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }) : undefined}
|
||||
onClick={(event) => {
|
||||
if (mode === "jobs" && !job.isDeleted && !isInteractiveTarget(event.target)) openJob(job.id);
|
||||
}}
|
||||
@@ -811,7 +827,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
>
|
||||
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell sx={{ minWidth: 140, fontWeight: 700 }}>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell sx={{ minWidth: 260 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography component="span" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
|
||||
{actionSignals.map((signal) => (
|
||||
@@ -825,7 +841,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
sx={{ fontWeight: 800, cursor: "pointer" }}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
aria-label={`${job.jobTitle} — ${signal.label} signal`}
|
||||
aria-label={t("jobTableWorkflowSignal", { title: job.jobTitle, signal: signal.label })}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -853,7 +869,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => { setPage(next); updateListRoute({ page: next > 0 ? String(next + 1) : null }); }} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); updateListRoute({ page: null }); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
<TablePagination component="div" labelRowsPerPage={t("settingsRowsPerPage")} labelDisplayedRows={({ from, to, count }) => t("tableDisplayedRows", { from, to, count })} getItemAriaLabel={(type) => t(type === "next" ? "tableNextPage" : type === "previous" ? "tablePreviousPage" : type === "first" ? "tableFirstPage" : "tableLastPage")} count={total} page={page} onPageChange={(_, next) => { setPage(next); updateListRoute({ page: next > 0 ? String(next + 1) : null }); }} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); updateListRoute({ page: null }); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
</Paper>
|
||||
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
|
||||
|
||||
@@ -1003,6 +1003,17 @@ export const translations = {
|
||||
securityCheck: "Security check",
|
||||
dashboardTotalCount: "{count} total",
|
||||
jobTableSelectJob: "Select {title}",
|
||||
listDensity: "List density",
|
||||
densityComfortable: "Comfortable",
|
||||
densityCompact: "Compact",
|
||||
jobTableScrollRegion: "Job table — scroll for more columns",
|
||||
jobTableOpenJob: "Open {title} at {company}",
|
||||
jobTableWorkflowSignal: "{title} — {signal} signal",
|
||||
tableDisplayedRows: "{from}–{to} of {count}",
|
||||
tableNextPage: "Go to next page",
|
||||
tablePreviousPage: "Go to previous page",
|
||||
tableFirstPage: "Go to first page",
|
||||
tableLastPage: "Go to last page",
|
||||
characterCount: "{count} characters",
|
||||
back: "Back",
|
||||
continue: "Continue",
|
||||
@@ -3443,6 +3454,17 @@ export const translations = {
|
||||
securityCheck: "Sikkerhetskontroll",
|
||||
dashboardTotalCount: "{count} totalt",
|
||||
jobTableSelectJob: "Velg {title}",
|
||||
listDensity: "Listetetthet",
|
||||
densityComfortable: "Komfortabel",
|
||||
densityCompact: "Kompakt",
|
||||
jobTableScrollRegion: "Jobbtabell — rull for å se flere kolonner",
|
||||
jobTableOpenJob: "Åpne {title} hos {company}",
|
||||
jobTableWorkflowSignal: "{title} — {signal}",
|
||||
tableDisplayedRows: "{from}–{to} av {count}",
|
||||
tableNextPage: "Gå til neste side",
|
||||
tablePreviousPage: "Gå til forrige side",
|
||||
tableFirstPage: "Gå til første side",
|
||||
tableLastPage: "Gå til siste side",
|
||||
characterCount: "{count} tegn",
|
||||
back: "Tilbake",
|
||||
continue: "Fortsett",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { 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";
|
||||
|
||||
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: /open 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.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();
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
|
||||
|
||||
type PaletteLike = Record<string, any>;
|
||||
|
||||
export type ContentDensity = "comfortable" | "compact";
|
||||
|
||||
// Existing provisional brand hue. Dark mode uses a lighter tone for readable text;
|
||||
// this does not settle the Phase 3 brand/identity decision.
|
||||
const ACCENT = "#5B5BD6";
|
||||
@@ -300,6 +302,31 @@ export const getTheme = (_mode: "light" | "dark") => {
|
||||
"@media (min-width: 1440px)": {
|
||||
":root": { "--app-page-gutter": "32px" },
|
||||
},
|
||||
// Opt-in MUI table/list spacing. Unmigrated consumers keep their defaults.
|
||||
"[data-content-density]": {
|
||||
"--app-table-cell-py": "12px",
|
||||
"--app-list-item-padding": "16px",
|
||||
"--app-list-gap": "12px",
|
||||
"--app-data-target": "32px",
|
||||
},
|
||||
'[data-content-density="compact"]': {
|
||||
"--app-table-cell-py": "8px",
|
||||
"--app-list-item-padding": "12px",
|
||||
"--app-list-gap": "8px",
|
||||
},
|
||||
// Density never shrinks hit regions. Coarse pointers and phone cards get
|
||||
// 44px targets in either mode; desktop controls keep at least 32px.
|
||||
"@media (pointer: coarse), (max-width: 767.95px)": {
|
||||
"[data-content-density]": { "--app-data-target": "44px" },
|
||||
},
|
||||
"[data-content-density] .MuiIconButton-root, [data-content-density] .MuiCheckbox-root": {
|
||||
minWidth: "var(--app-data-target)", minHeight: "var(--app-data-target)",
|
||||
},
|
||||
"[data-content-density] .MuiTableSortLabel-root, [data-content-density] .MuiChip-clickable, [data-content-density] .MuiButton-root, [data-content-density] a": {
|
||||
minHeight: "var(--app-data-target)",
|
||||
},
|
||||
"[data-content-density] a": { display: "inline-flex", alignItems: "center" },
|
||||
"[data-content-density] .MuiTableCell-paddingCheckbox": { width: "auto" },
|
||||
body: {
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
MozOsxFontSmoothing: "grayscale",
|
||||
@@ -525,8 +552,8 @@ export const getTheme = (_mode: "light" | "dark") => {
|
||||
background: theme.vars.palette.grey[100],
|
||||
}),
|
||||
root: {
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
paddingTop: "var(--app-table-cell-py, 10px)",
|
||||
paddingBottom: "var(--app-table-cell-py, 10px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user