feat(redesign): migrate Jobs Board presentation (P5-04a)
@@ -0,0 +1,51 @@
|
|||||||
|
# P5-04a — Board presentation
|
||||||
|
|
||||||
|
Source: [approved Phase 5 plan](../PHASE5_PLAN.md#p5-04a--board-presentation). This checkpoint starts at `7127005e48703eb2147e6f44bd98975975d0eb62`. It changes the existing `/kanban` presentation and its focused tests. It does not merge Table and Board, change the board API or status values, or start P5-04b.
|
||||||
|
|
||||||
|
## Preservation and implementation
|
||||||
|
|
||||||
|
The Board still reads every owner-visible job from `/jobapplications/board`, groups standard stages into Not Applied, Active and Closed, and shows custom/unknown statuses in Other. A group drop still applies only its existing entry stage (Saved, Applied or Rejected); the card menu still offers precise stages. Other is not a drop target. The existing ordering, announcements, retry/error state, permission and session boundaries remain. No new data source, backend field, status mutation, route, drag library or saved preference was added.
|
||||||
|
|
||||||
|
Application files changed: `job-tracker-ui/src/components/KanbanBoard.tsx` and the Board labels in `job-tracker-ui/src/i18n/translations.ts`. Test files changed: `src/kanban-grouped-board.test.tsx`, `e2e/kanban-theme.spec.ts`, new `e2e/board-presentation.spec.ts`, and the narrow keyboard-path fixture in `e2e/semantic-foundations.spec.ts`. The last file changes no application behavior: its old programmatic focus → Tab → reverse-Tab sequence repeatedly measured a false 0px command ring in dark mode, while real keyboard Tab from the search input retained the required 3px ring. The fixture now asserts that real path without weakening the ring requirement.
|
||||||
|
|
||||||
|
The column and card surfaces now use the Phase 4 semantic palette in both themes. Cards have a clearer title/company/location/status/deadline hierarchy, a persistent status action, compact readable spacing and visible keyboard/drag-target focus. Column headings are semantic headings with localized accessible names. Cards are articles containing separate open and status controls, avoiding nested interactive semantics. Long English/Bokmål content wraps. A horizontal column rail remains below the desktop grid threshold, including phone and tablet widths; the page itself does not overflow. The Table link and current Board indicator are visible, with explicit copy that Board currently shows all tracked jobs and Table filters do not transfer. This is navigation clarity, not integration.
|
||||||
|
|
||||||
|
The existing Board job shape has no priority/favourite flag, so the cards do not invent one. The existing salary/follow-up/deadline pill still chooses one grounded signal, now with a localized deadline label/date. Standard stages retain the shared status-tone semantics and a text label; custom values remain raw text with a neutral tone, never silently translated or remapped.
|
||||||
|
|
||||||
|
Opening a card takes the existing `/jobs/:id` workspace route. The originating `/kanban` history entry stores only the numeric focus target; Browser Back returns to the same URL/query and focuses that card after Board data loads. Successful keyboard/pointer/menu moves focus the moved card in its resulting group; failed moves retain the original card/status and return focus. The title/card control uses Enter to open and Space to pick up/cancel for keyboard movement, while the status menu remains a distinct action. No ownership or job data enters history state.
|
||||||
|
|
||||||
|
| PRES contract | Existing behavior retained and P5-04a change | Automated evidence | Visual/UX evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| PRES-J07 | Full grouped Board, custom Other, pointer drag, keyboard pickup/drop/cancel, group entry statuses, precise menu, failure/retry and announcements → semantic columns/cards and move focus | `src/kanban-grouped-board.test.tsx`; `e2e/board-presentation.spec.ts`; `e2e/kanban-theme.spec.ts`; backend JobPipeline tests | Populated/empty/error, active target, successful/failed move, themes/locales and widths in `visual/` |
|
||||||
|
| PRES-J05 | `/jobs` Table filters, query/page and Board's separate full population remain distinct → visible Table/Board links and explanatory scope copy | Native 27-job browser fixture proves Board 27 while Table's `q=Board` page contains 15 of 26 matches | Desktop/phone captures show the links and scope copy |
|
||||||
|
| PRES-W01/W02 | Dedicated job route, status/progression and return context → card activation to `/jobs/:id`, Browser Back focus, unchanged status API | Unit route-state test and native browser open/Back/move tests | Focus/move captures; no workspace layout change |
|
||||||
|
| PRES-E07/E08/E09 | Shell entry, light/dark, EN/Bokmål, owner-private Board data and session/permission boundary → localized Board labels and semantic theme use | Existing shell/session suites, scoped axe and native owner fixture | Four theme/locale combinations; desktop/phone/320px captures |
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Inherited baseline: backend **918**, frontend **377**, browser **74 actual passes + unchanged P2-D01 expected failure**, rate-limit **9**, ingress **6**, PDF admission **1**, configuration **3**. The focused Board unit suite passes **12/12**; four new cases cover full population/navigation, keyboard cancellation/success/focus, direct open/return and precise status-menu behavior. The new native browser fixture covers 27 jobs, a custom status, pointer and keyboard movement, failure, Browser Back, 1440/1024/768/390/320px, themes/locales, scoped axe, loaded/error/retry/empty states. The existing `kanban-theme` test was updated to assert semantic surfaces and visible focus rather than the removed decorative hover lift.
|
||||||
|
|
||||||
|
Validation commands: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj -c Release --no-restore`, `npm test -- --runInBand --silent`, `npm run typecheck`, `npm run lint`, `npm run build` with the Playwright API URL embedded, `dotnet build JobTrackerApi/JobTrackerApi.csproj -c Release --no-restore -warnaserror`, `npm run test:e2e` with the static export, `npm run test:rate-limits`, Playwright ingress/PDF configurations, `python scripts/test-ingress.py`, `python scripts/test-supply-chain.py`, `python scripts/test-ollama-evaluation.py`, `node --test browser-extension/tests/*.test.mjs`, tracked-secret scan, `git diff --check`, and local Markdown-link resolution. The isolated API/browser suites use fresh local fixtures; no production service was contacted.
|
||||||
|
|
||||||
|
Final post-change baseline: backend **918/918**, frontend **381/381** (**+4** Board cases), production-static Playwright **76 actual passes + unchanged P2-D01 expected failure** (**77 reported passed**, zero unexpected failures; **+2** Board cases). Rate-limit **9/9**, ingress **6/6**, PDF admission **1/1**, configuration **3/3**, supply-chain **3/3**, model eval **5/5**, browser extension **3/3**, TypeScript, lint, frontend build, API Release build, tracked-secret scan, whitespace and local document links all passed. The first full browser run had one inherited command-focus fixture failure from its programmatic focus sequence; an isolated real-keyboard check and the second complete run passed with the 3px ring assertion retained. No application code changed between these two full browser runs.
|
||||||
|
|
||||||
|
Visual evidence: populated [light English desktop](visual/board-desktop-light-en.png), [dark Bokmål desktop](visual/board-desktop-dark-nb.png), [light English phone](visual/board-phone-light-en.png), [dark Bokmål phone](visual/board-phone-dark-nb.png), and [320px dark Bokmål](visual/board-phone-320-dark-nb.png). The full light/dark × EN/Bokmål desktop/phone set is in `visual/`, along with [active keyboard target](visual/board-active-drop-dark-nb.png), [keyboard move](visual/board-keyboard-move-dark-nb.png), [pointer move](visual/board-pointer-move-dark-nb.png), [custom-status move](visual/board-custom-status-dark-nb.png), [failed move](visual/board-failed-move-dark-nb.png), [loading](visual/board-loading-light-en.png), [error/retry](visual/board-error-light-en.png), and [fully empty columns](visual/board-empty-light-en.png). Browser screenshots were inspected directly; long text and actions remain readable, the narrow phone shows one usable column and a clear next-column edge, and both themes retain status and focus contrast. Automated axe is scoped to WCAG A/AA and is not spoken screen-reader verification.
|
||||||
|
|
||||||
|
## P5-04b read/query parity gate
|
||||||
|
|
||||||
|
**NO-GO for P5-04b integration.** The approved read/query contract is still missing:
|
||||||
|
|
||||||
|
| Boundary | Observed P5-04a behavior | P5-04b requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| Filters/search/query | Table `/jobapplications` accepts `q`, filters, sorting and pagination. Board `/jobapplications/board` has no corresponding filter/query contract; `/kanban?q=table-only` preserves the URL but Board still shows all jobs. | Explicit, accepted shared query and URL/saved-view semantics. |
|
||||||
|
| Population/results | Native owner fixture: Board shows all 27; Table `q=Board&pageSize=15` reports 26 matches and returns 15 on its first page. | Same complete filtered population, including >25 rows, ordering, custom/deleted/follow-up cases. |
|
||||||
|
| Status/custom status | Both use the existing pipeline values; Board groups standard statuses and places the custom value in Other. | Preserve this mapping across a shared view switch. |
|
||||||
|
| Navigation | `/jobs` and `/kanban` remain separate routes; job cards open `/jobs/:id` and restore `/kanban` focus/query on Back. | Only after read parity: compatible `view=table|board` and `/kanban` alias preserving safe query/hash/state. |
|
||||||
|
|
||||||
|
P5-04b needs separately accepted owner-scoped read/query contract work and native parity tests before its route/workspace integration can start. This checkpoint does not silently replace full-board access with a first-page subset.
|
||||||
|
|
||||||
|
## Open acceptance and rollback
|
||||||
|
|
||||||
|
P5-02-D01 manual screen-reader spoken-name/live-announcement acceptance remains open for P5-15; Board axe/keyboard checks do not close it. P2-D01 remains the documented expected native-package browser failure. F04 remains open pending production ingress evidence. SQLite contention, CV rendering fidelity and other separately owned debt are unchanged. Touch-specific native HTML drag is not claimed from desktop automation; the visible status action and keyboard move remain available alternatives on narrow screens.
|
||||||
|
|
||||||
|
Rollback boundary: revert `KanbanBoard` presentation/activation/focus and its localization/test changes together. The existing `/kanban` route, read endpoint and status mutation contract are unchanged.
|
||||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,190 @@
|
|||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
import { expect, test, type APIRequestContext, 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(cookie => cookie.name === "XSRF-TOKEN")!.value;
|
||||||
|
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(page: Page, info: TestInfo, name: string) {
|
||||||
|
const path = 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([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("[PRES-J07 J05 W01 W02 E07 E08 E09] full Board keeps its own population, moves, focus and route", async ({ page, playwright, context }, info) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const admin = await playwright.request.newContext();
|
||||||
|
const ids: number[] = [];
|
||||||
|
let headers: Record<string, string>;
|
||||||
|
try {
|
||||||
|
const adminHeaders = await login(admin, "e2e@example.test");
|
||||||
|
const email = `p504a-${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);
|
||||||
|
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;
|
||||||
|
for (let index = 0; index < 27; index++) {
|
||||||
|
const result = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: {
|
||||||
|
companyId,
|
||||||
|
jobTitle: index === 0 ? "Seniorrådgiver for plattformutvikling og digitale fellestjenester" : `Board ${String(index).padStart(2, "0")} Engineer`,
|
||||||
|
status: index === 1 ? "Offer" : index === 2 ? "Take-home assignment" : "Saved",
|
||||||
|
location: "Tromsø / hjemmekontor",
|
||||||
|
dateApplied: "2026-08-01",
|
||||||
|
deadline: "2026-10-15",
|
||||||
|
} });
|
||||||
|
expect(result.status()).toBe(201);
|
||||||
|
ids.push((await result.json()).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto("/kanban?q=table-only");
|
||||||
|
const cards = page.locator("[data-kanban-card-id]");
|
||||||
|
await expect(cards).toHaveCount(27);
|
||||||
|
expect(new URL(page.url()).search).toBe("?q=table-only");
|
||||||
|
expect((await page.request.get(`${apiUrl}/jobapplications/board`)).status()).toBe(200);
|
||||||
|
const tablePage = await (await page.request.get(`${apiUrl}/jobapplications?page=1&pageSize=15&q=Board`)).json();
|
||||||
|
expect(tablePage.items.length).toBe(15);
|
||||||
|
expect(tablePage.total).toBe(26);
|
||||||
|
|
||||||
|
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
|
||||||
|
const t = translations[language];
|
||||||
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204);
|
||||||
|
await page.evaluate(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
|
||||||
|
await expect(cards).toHaveCount(27);
|
||||||
|
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) })).toBeVisible();
|
||||||
|
await expect(page.getByRole("group", { name: t.kanbanColumnNotDropTarget.replace("{label}", t.kanbanGroupOther) })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: t.jobApplications })).toHaveAttribute("href", "/jobs");
|
||||||
|
await capture(page, info, `board-desktop-${mode}-${language}`);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) })).toBeVisible();
|
||||||
|
await capture(page, info, `board-phone-${mode}-${language}`);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||||
|
if (language === "en") await scan(page, info, `board-phone-${mode}`);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.setViewportSize({ width: 1024, height: 900 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||||
|
await page.setViewportSize({ width: 768, height: 900 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||||
|
await page.setViewportSize({ width: 320, height: 720 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||||
|
await capture(page, info, "board-phone-320-dark-nb");
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
|
||||||
|
const t = translations.nb;
|
||||||
|
const saved = page.locator(`[data-kanban-card-id="${ids[0]}"]`);
|
||||||
|
const savedControl = saved.locator('[role="button"]');
|
||||||
|
const active = page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupActive) });
|
||||||
|
const other = page.getByRole("group", { name: t.kanbanColumnNotDropTarget.replace("{label}", t.kanbanGroupOther) });
|
||||||
|
await savedControl.focus();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await expect(savedControl).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await other.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(other).toHaveAttribute("data-drop-state", "invalid");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(savedControl).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await savedControl.focus(); await page.keyboard.press("Space");
|
||||||
|
await active.focus();
|
||||||
|
await capture(page, info, "board-active-drop-dark-nb");
|
||||||
|
const move = page.waitForResponse(response => response.url() === `${apiUrl}/jobapplications/${ids[0]}/status` && response.request().method() === "PATCH");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
expect((await move).status()).toBe(204);
|
||||||
|
await expect(active.locator(`[data-kanban-card-id="${ids[0]}"] [role="button"]`)).toBeFocused();
|
||||||
|
await capture(page, info, "board-keyboard-move-dark-nb");
|
||||||
|
|
||||||
|
const pointerCard = page.locator(`[data-kanban-card-id="${ids[25]}"]`);
|
||||||
|
const closed = page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupClosed) });
|
||||||
|
await expect(pointerCard).toContainText("Board 25 Engineer");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.addEventListener("dragstart", event => {
|
||||||
|
(window as typeof window & { __boardDragSource?: string }).__boardDragSource =
|
||||||
|
(event.target as HTMLElement).closest("[data-kanban-card-id]")?.getAttribute("data-kanban-card-id") ?? "";
|
||||||
|
}, { capture: true, once: true });
|
||||||
|
});
|
||||||
|
const pointerMove = page.waitForResponse(response => response.url() === `${apiUrl}/jobapplications/${ids[25]}/status` && response.request().method() === "PATCH", { timeout: 15_000 });
|
||||||
|
await pointerCard.dragTo(closed, { timeout: 15_000 });
|
||||||
|
expect(await page.evaluate(() => (window as typeof window & { __boardDragSource?: string }).__boardDragSource)).toBe(String(ids[25]));
|
||||||
|
expect((await pointerMove).status()).toBe(204);
|
||||||
|
await expect(closed.locator(`[data-kanban-card-id="${ids[25]}"]`)).toBeVisible();
|
||||||
|
await expect(closed.locator(`[data-kanban-card-id="${ids[25]}"] [role="button"]`)).toBeFocused();
|
||||||
|
await capture(page, info, "board-pointer-move-dark-nb");
|
||||||
|
|
||||||
|
const customCard = page.locator(`[data-kanban-card-id="${ids[2]}"]`);
|
||||||
|
await expect(customCard.getByText("Take-home assignment", { exact: true })).toBeVisible();
|
||||||
|
await customCard.getByRole("button", { name: `${t.jobTableQuickStatus}: Board 02 Engineer` }).click();
|
||||||
|
const preciseMove = page.waitForResponse(response => response.url() === `${apiUrl}/jobapplications/${ids[2]}/status` && response.request().method() === "PATCH");
|
||||||
|
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview) }).click();
|
||||||
|
expect((await preciseMove).status()).toBe(204);
|
||||||
|
await expect(active.locator(`[data-kanban-card-id="${ids[2]}"] [role="button"]`)).toBeFocused();
|
||||||
|
await capture(page, info, "board-custom-status-dark-nb");
|
||||||
|
|
||||||
|
await page.route(`**/api/jobapplications/${ids[4]}/status`, route => route.fulfill({ status: 503, json: { message: "Synthetic move failure" } }));
|
||||||
|
const failed = page.locator(`[data-kanban-card-id="${ids[4]}"]`);
|
||||||
|
await failed.getByRole("button", { name: `${t.jobTableQuickStatus}: Board 04 Engineer` }).click();
|
||||||
|
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview) }).click();
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Synthetic move failure" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) }).locator(`[data-kanban-card-id="${ids[4]}"]`)).toBeVisible();
|
||||||
|
await capture(page, info, "board-failed-move-dark-nb");
|
||||||
|
await page.unroute(`**/api/jobapplications/${ids[4]}/status`);
|
||||||
|
|
||||||
|
await savedControl.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/jobs/${ids[0]}$`));
|
||||||
|
await expect(page.getByRole("heading", { name: /Seniorrådgiver/ })).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(url => url.pathname === "/kanban" && url.search === "?q=table-only");
|
||||||
|
await expect(savedControl).toBeFocused();
|
||||||
|
} finally {
|
||||||
|
await admin.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[PRES-J07 E08] Board loading, retryable error and fully empty columns", async ({ page }, info) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Email").fill("e2e@example.test");
|
||||||
|
await page.getByLabel("Current password").fill(password);
|
||||||
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
let release!: () => void;
|
||||||
|
let state: "loading" | "error" | "empty" = "loading";
|
||||||
|
await page.route("**/api/jobapplications/board", async route => {
|
||||||
|
if (state === "loading") await new Promise<void>(resolve => { release = resolve; });
|
||||||
|
if (state === "error") await route.fulfill({ status: 503, json: { message: "Synthetic board outage" } });
|
||||||
|
else await route.fulfill({ json: [] });
|
||||||
|
});
|
||||||
|
await page.goto("/kanban", { waitUntil: "domcontentloaded" });
|
||||||
|
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||||
|
await capture(page, info, "board-loading-light-en");
|
||||||
|
state = "error"; release();
|
||||||
|
await expect(page.getByText("Unable to load the kanban board")).toBeVisible();
|
||||||
|
await capture(page, info, "board-error-light-en");
|
||||||
|
state = "empty";
|
||||||
|
await page.getByRole("button", { name: "Retry" }).click();
|
||||||
|
await expect(page.getByText("No jobs in this group yet.")).toHaveCount(3);
|
||||||
|
await capture(page, info, "board-empty-light-en");
|
||||||
|
await scan(page, info, "board-empty-light-en");
|
||||||
|
});
|
||||||
@@ -47,14 +47,15 @@ test("kanban columns use dark theme surfaces", async ({ page }) => {
|
|||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/kanban");
|
await page.goto("/kanban");
|
||||||
await expect(page.getByText("Synthetic platform engineer")).toBeVisible();
|
await expect(page.getByText("Synthetic platform engineer")).toBeVisible();
|
||||||
const column = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]");
|
const column = page.getByRole("group", { name: "Not Applied column" });
|
||||||
expect(await column.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe("rgb(245, 242, 250)");
|
const darkSurface = await column.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "ux-003-kanban-dark-after.png"), fullPage: true });
|
if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "ux-003-kanban-dark-after.png"), fullPage: true });
|
||||||
|
|
||||||
const card = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
const card = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
||||||
await card.focus();
|
const cardControl = card.locator('[role="button"]');
|
||||||
|
await cardControl.focus();
|
||||||
await page.keyboard.press("Space");
|
await page.keyboard.press("Space");
|
||||||
await expect(card).toHaveAttribute("aria-pressed", "true");
|
await expect(cardControl).toHaveAttribute("aria-pressed", "true");
|
||||||
const active = page.getByRole("group", { name: "Active column" });
|
const active = page.getByRole("group", { name: "Active column" });
|
||||||
await expect(active).toHaveAttribute("data-drop-state", "valid");
|
await expect(active).toHaveAttribute("data-drop-state", "valid");
|
||||||
await expect(page.getByRole("group", { name: "Other column, not a drop target" })).toHaveAttribute("aria-disabled", "true");
|
await expect(page.getByRole("group", { name: "Other column, not a drop target" })).toHaveAttribute("aria-disabled", "true");
|
||||||
@@ -86,11 +87,11 @@ test("kanban columns use dark theme surfaces", async ({ page }) => {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light");
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light");
|
||||||
const lightColumn = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]");
|
const lightColumn = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]");
|
||||||
expect(await lightColumn.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe("rgb(245, 242, 250)");
|
expect(await lightColumn.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(darkSurface);
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
const lightCard = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
const lightCard = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
||||||
await lightCard.hover();
|
await lightCard.hover();
|
||||||
expect(await lightCard.evaluate((element) => getComputedStyle(element).transform)).not.toBe("none");
|
await expect(lightCard.getByRole("button", { name: "Quick status: Synthetic platform engineer" })).toBeVisible();
|
||||||
await lightCard.dragTo(page.getByRole("group", { name: "Active column" }));
|
await lightCard.dragTo(page.getByRole("group", { name: "Active column" }));
|
||||||
await expect.poll(() => moveRequests).toBe(2);
|
await expect.poll(() => moveRequests).toBe(2);
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ for (const mode of ["light", "dark"] as const) {
|
|||||||
await keyboardFocus(page, dialog.getByRole("textbox"));
|
await keyboardFocus(page, dialog.getByRole("textbox"));
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
const command = dialog.getByRole("button").first();
|
const command = dialog.getByRole("button").first();
|
||||||
await keyboardFocus(page, command);
|
await expect(command).toBeFocused();
|
||||||
|
await expect.poll(() => command.evaluate(node => getComputedStyle(node).outlineWidth)).toBe("3px");
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(dialog).not.toBeVisible();
|
await expect(dialog).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||||
|
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import ViewStateNotice from "./ViewStateNotice";
|
import ViewStateNotice from "./ViewStateNotice";
|
||||||
@@ -40,19 +42,6 @@ type Status = PipelineStatus;
|
|||||||
// belong to no group, and only appears when something is actually in it.
|
// belong to no group, and only appears when something is actually in it.
|
||||||
type ColumnKey = PipelineGroup | "Other";
|
type ColumnKey = PipelineGroup | "Other";
|
||||||
|
|
||||||
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
|
||||||
error: (theme) => (theme.vars?.palette ?? theme.palette).error.main,
|
|
||||||
warning: (theme) => (theme.vars?.palette ?? theme.palette).warning.main,
|
|
||||||
success: (theme) => (theme.vars?.palette ?? theme.palette).success.main,
|
|
||||||
info: (theme) => (theme.vars?.palette ?? theme.palette).info.main,
|
|
||||||
primary: (theme) => (theme.vars?.palette ?? theme.palette).primary.main,
|
|
||||||
default: (theme) => (theme.vars?.palette ?? theme.palette).primary.main,
|
|
||||||
};
|
|
||||||
|
|
||||||
function toneColor(theme: any, status: Status | "Other"): string {
|
|
||||||
return TONE_PALETTE[statusTone(status)](theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mockup's Applied-column cards carry small skill-tag pills ("React", "TS") sourced from the
|
// Mockup's Applied-column cards carry small skill-tag pills ("React", "TS") sourced from the
|
||||||
// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
|
// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
|
||||||
function parseTags(raw?: string): string[] {
|
function parseTags(raw?: string): string[] {
|
||||||
@@ -71,7 +60,7 @@ type PillTone = "error" | "warning" | "success" | "info";
|
|||||||
// Built entirely from fields the board already fetches (needsFollowUp/followUpAt/salary/deadline)
|
// Built entirely from fields the board already fetches (needsFollowUp/followUpAt/salary/deadline)
|
||||||
// -- there's no interview-datetime field on the board's JobApplication shape, so an "Interview
|
// -- there's no interview-datetime field on the board's JobApplication shape, so an "Interview
|
||||||
// tomorrow 14:00"-style pill isn't reproducible; deadline is used as the closest real fallback.
|
// tomorrow 14:00"-style pill isn't reproducible; deadline is used as the closest real fallback.
|
||||||
function cardPill(job: JobApplication, t: (key: any, params?: any) => string): { text: string; tone: PillTone } | null {
|
function cardPill(job: JobApplication, t: (key: any, params?: any) => string, language: string): { text: string; tone: PillTone } | null {
|
||||||
if (job.needsFollowUp) {
|
if (job.needsFollowUp) {
|
||||||
const due = job.followUpAt ? new Date(job.followUpAt) : null;
|
const due = job.followUpAt ? new Date(job.followUpAt) : null;
|
||||||
if (!due || due.getTime() <= Date.now()) return { text: t("kanbanFollowUpNow"), tone: "error" };
|
if (!due || due.getTime() <= Date.now()) return { text: t("kanbanFollowUpNow"), tone: "error" };
|
||||||
@@ -79,15 +68,18 @@ function cardPill(job: JobApplication, t: (key: any, params?: any) => string): {
|
|||||||
return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
|
return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
|
||||||
}
|
}
|
||||||
if (job.salary) return { text: job.salary, tone: "success" };
|
if (job.salary) return { text: job.salary, tone: "success" };
|
||||||
if (job.deadline) return { text: new Date(job.deadline).toLocaleDateString(), tone: "info" };
|
if (job.deadline) return { text: t("jobDiscoveryDeadline", { date: new Date(job.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") }), tone: "info" };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanBoard() {
|
export default function KanbanBoard() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const palette = theme.vars?.palette ?? theme.palette;
|
const palette = theme.vars?.palette ?? theme.palette;
|
||||||
const customShadows = (theme.vars as any)?.customShadows ?? (theme as any).customShadows;
|
const { t, language } = useI18n();
|
||||||
const { t } = useI18n();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const focusAfterMoveRef = useRef<number | null>(null);
|
||||||
|
const restoredFocusRef = useRef<number | null>(null);
|
||||||
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
|
const [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
|
||||||
const [moveError, setMoveError] = useState("");
|
const [moveError, setMoveError] = useState("");
|
||||||
@@ -109,6 +101,23 @@ export default function KanbanBoard() {
|
|||||||
|
|
||||||
const jobs = jobsResource.data;
|
const jobs = jobsResource.data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jobsResource.loading || jobsResource.error) return;
|
||||||
|
const returnId = window.history.state?.jobBoardFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
|
||||||
|
const targetId = focusAfterMoveRef.current ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
|
||||||
|
if (targetId == null) return;
|
||||||
|
const card = document.querySelector<HTMLElement>(`[data-kanban-card-id="${targetId}"] [role="button"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.focus();
|
||||||
|
focusAfterMoveRef.current = null;
|
||||||
|
restoredFocusRef.current = targetId;
|
||||||
|
}, [jobs, jobsResource.loading, jobsResource.error, location.state]);
|
||||||
|
|
||||||
|
const openJob = (id: number) => {
|
||||||
|
window.history.replaceState({ ...window.history.state, jobBoardFocusId: id }, "");
|
||||||
|
navigate(`/jobs/${id}`, { state: { from: `${location.pathname}${location.search}`, focusJobId: id } });
|
||||||
|
};
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const map = new Map<ColumnKey, JobApplication[]>();
|
const map = new Map<ColumnKey, JobApplication[]>();
|
||||||
PIPELINE_GROUPS.forEach((g) => map.set(g.key, []));
|
PIPELINE_GROUPS.forEach((g) => map.set(g.key, []));
|
||||||
@@ -142,11 +151,13 @@ export default function KanbanBoard() {
|
|||||||
setMoveError("");
|
setMoveError("");
|
||||||
try {
|
try {
|
||||||
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
|
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
|
||||||
|
focusAfterMoveRef.current = movingJobId;
|
||||||
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
|
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
|
||||||
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
||||||
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
|
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
|
||||||
|
document.querySelector<HTMLElement>(`[data-kanban-card-id="${movingJobId}"] [role="button"]`)?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,11 +165,13 @@ export default function KanbanBoard() {
|
|||||||
setMoveError("");
|
setMoveError("");
|
||||||
try {
|
try {
|
||||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||||
|
focusAfterMoveRef.current = id;
|
||||||
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
||||||
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
||||||
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
|
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
|
||||||
|
document.querySelector<HTMLElement>(`[data-kanban-card-id="${id}"] [role="button"]`)?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,9 +193,16 @@ export default function KanbanBoard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
|
<Box sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 1.5, flexDirection: { xs: "column", sm: "row" }, mb: 2 }}>
|
||||||
{t("kanbanHint")}
|
<Box>
|
||||||
</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("kanbanHint")}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("kanbanSeparateViews")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexShrink: 0 }}>
|
||||||
|
<Button component={RouterLink} to="/jobs" variant="outlined" size="small">{t("jobApplications")}</Button>
|
||||||
|
<Button variant="contained" size="small" aria-current="page" disabled>{t("kanbanBoard")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<ViewStateNotice
|
<ViewStateNotice
|
||||||
loading={jobsResource.loading}
|
loading={jobsResource.loading}
|
||||||
@@ -195,23 +215,23 @@ export default function KanbanBoard() {
|
|||||||
{!jobsResource.loading && !jobsResource.error ? (
|
{!jobsResource.loading && !jobsResource.error ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: "flex", md: "grid" },
|
display: { xs: "flex", lg: "grid" },
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
gridTemplateColumns: { md: "repeat(3, 1fr)" },
|
gridTemplateColumns: { lg: `repeat(${columns.length}, minmax(0, 1fr))` },
|
||||||
gap: 2,
|
gap: 1.5,
|
||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
overflowX: { xs: "auto", md: "visible" },
|
overflowX: { xs: "auto", lg: "visible" },
|
||||||
scrollSnapType: { xs: "x mandatory", md: "none" },
|
scrollSnapType: { xs: "x mandatory", lg: "none" },
|
||||||
pb: { xs: 1, md: 0 },
|
pb: { xs: 1, lg: 0 },
|
||||||
WebkitOverflowScrolling: "touch",
|
WebkitOverflowScrolling: "touch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map(({ key, label, droppable }) => {
|
{columns.map(({ key, label, droppable }) => {
|
||||||
const list = groups.get(key) ?? [];
|
const list = groups.get(key) ?? [];
|
||||||
// Colour the column by its entry stage so the header dot still carries meaning.
|
const tone = droppable ? statusTone(GROUP_ENTRY_STATUS[key as PipelineGroup]) : "default";
|
||||||
const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : palette.text.disabled;
|
const c = tone === "default" ? palette.text.secondary : palette[tone].main;
|
||||||
const isDragActive = dragJobId !== null;
|
const isDragActive = dragJobId !== null;
|
||||||
const isActiveTarget = dragOverColumn === key;
|
const isActiveTarget = dragOverColumn === key;
|
||||||
return (
|
return (
|
||||||
@@ -219,7 +239,7 @@ export default function KanbanBoard() {
|
|||||||
key={key}
|
key={key}
|
||||||
component="section"
|
component="section"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`${label} column${droppable ? "" : ", not a drop target"}`}
|
aria-label={t(droppable ? "kanbanColumnLabel" : "kanbanColumnNotDropTarget", { label })}
|
||||||
aria-disabled={isDragActive && !droppable ? true : undefined}
|
aria-disabled={isDragActive && !droppable ? true : undefined}
|
||||||
tabIndex={isDragActive ? 0 : -1}
|
tabIndex={isDragActive ? 0 : -1}
|
||||||
data-drop-state={isActiveTarget ? (droppable ? "active" : "invalid") : isDragActive ? (droppable ? "valid" : "invalid") : "idle"}
|
data-drop-state={isActiveTarget ? (droppable ? "active" : "invalid") : isDragActive ? (droppable ? "valid" : "invalid") : "idle"}
|
||||||
@@ -239,24 +259,25 @@ export default function KanbanBoard() {
|
|||||||
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
|
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
|
||||||
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.25,
|
||||||
borderRadius: 3,
|
borderRadius: 2.5,
|
||||||
minHeight: 220,
|
minHeight: 220,
|
||||||
flex: { xs: "0 0 85vw", md: "none" },
|
flex: { xs: "0 0 min(85vw, 340px)", lg: "none" },
|
||||||
scrollSnapAlign: { xs: "start", md: "none" },
|
scrollSnapAlign: { xs: "start", lg: "none" },
|
||||||
border: `1px ${isDragActive ? "dashed" : "solid"} ${isActiveTarget ? palette.primary.main : palette.divider}`,
|
border: "1px solid",
|
||||||
outline: isActiveTarget ? `2px solid ${droppable ? palette.primary.main : palette.error.main}` : "none",
|
borderColor: isActiveTarget ? (droppable ? "primary.main" : "error.main") : "divider",
|
||||||
|
outline: isActiveTarget ? "2px solid" : "none",
|
||||||
|
outlineColor: droppable ? "primary.main" : "error.main",
|
||||||
outlineOffset: 2,
|
outlineOffset: 2,
|
||||||
opacity: isDragActive && !droppable ? 0.65 : 1,
|
backgroundColor: isActiveTarget && droppable ? "action.hover" : "background.paper",
|
||||||
backgroundColor: isActiveTarget && droppable ? palette.action.hover : palette.grey[100],
|
transition: "background-color .15s, border-color .15s, outline-color .15s",
|
||||||
transition: "background-color .15s, border-color .15s, outline-color .15s, opacity .15s",
|
"&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: 2 },
|
||||||
"&:focus-visible": { boxShadow: customShadows?.focus },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25, gap: 1 }}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
|
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
<Typography component="h2" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -267,7 +288,7 @@ export default function KanbanBoard() {
|
|||||||
px: 0.75,
|
px: 0.75,
|
||||||
py: 0.15,
|
py: 0.15,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: palette.grey[300],
|
backgroundColor: "action.hover",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||||
@@ -278,75 +299,76 @@ export default function KanbanBoard() {
|
|||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
{list.map((j) => {
|
{list.map((j) => {
|
||||||
const pill = cardPill(j, t);
|
const pill = cardPill(j, t, language);
|
||||||
const tags = parseTags(j.tags);
|
const tags = parseTags(j.tags);
|
||||||
// The column is a group, so each card names its own stage — grouping collapses
|
// The column is a group, so each card names its own stage — grouping collapses
|
||||||
// the columns, not the information.
|
// the columns, not the information.
|
||||||
const cardStatus = normalizeStatus(j.status);
|
const cardStatus = normalizeStatus(j.status);
|
||||||
const cardColor = toneColor(theme, cardStatus);
|
const cardTone = statusTone(cardStatus);
|
||||||
|
const cardColor = cardTone === "default" ? palette.text.secondary : palette[cardTone].main;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={j.id}
|
key={j.id}
|
||||||
|
component="article"
|
||||||
|
data-kanban-card-id={j.id}
|
||||||
draggable
|
draggable
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-pressed={dragJobId === j.id}
|
|
||||||
aria-label={t(dragJobId === j.id ? "kanbanCardPickedUpLabel" : "kanbanCardPickUpLabel", { title: j.jobTitle, status: statusLabel(t, j.status) })}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Escape" && dragJobId === j.id) {
|
|
||||||
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
|
|
||||||
} else if (event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
const pickingUp = dragJobId !== j.id;
|
|
||||||
setDragJobId(pickingUp ? j.id : null);
|
|
||||||
setDragOverColumn(null);
|
|
||||||
setAnnouncement(pickingUp ? t("kanbanPickedUpAnnouncement", { title: j.jobTitle }) : t("kanbanKeyboardMoveCancelled"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragStart={() => { setDragJobId(j.id); setAnnouncement(t("kanbanDragStartedAnnouncement", { title: j.jobTitle })); }}
|
onDragStart={() => { setDragJobId(j.id); setAnnouncement(t("kanbanDragStartedAnnouncement", { title: j.jobTitle })); }}
|
||||||
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
|
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
borderRadius: 2.5,
|
borderRadius: 2,
|
||||||
borderLeft: `4px solid ${cardColor}`,
|
border: "1px solid",
|
||||||
transition: "box-shadow .15s, transform .15s",
|
borderColor: "divider",
|
||||||
outline: dragJobId === j.id ? `2px solid ${palette.primary.main}` : "none",
|
borderLeft: `3px solid ${cardColor}`,
|
||||||
|
backgroundColor: "background.elevated",
|
||||||
|
boxShadow: "none",
|
||||||
|
transition: "border-color .15s, background-color .15s",
|
||||||
|
outline: dragJobId === j.id ? "2px solid" : "none",
|
||||||
|
outlineColor: "primary.main",
|
||||||
outlineOffset: 2,
|
outlineOffset: 2,
|
||||||
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
|
"&:hover": { borderColor: "border.strong", backgroundColor: "action.hover" },
|
||||||
"&:focus-visible": { boxShadow: customShadows?.focus },
|
|
||||||
"&:active": { cursor: "grabbing" },
|
"&:active": { cursor: "grabbing" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
<CardContent sx={{ position: "relative", p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
<Box
|
||||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={dragJobId === j.id}
|
||||||
|
aria-label={t(dragJobId === j.id ? "kanbanCardPickedUpLabel" : "kanbanCardPickUpLabel", { title: j.jobTitle, status: statusLabel(t, j.status) })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape" && dragJobId === j.id) {
|
||||||
|
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
|
||||||
|
} else if (event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
const pickingUp = dragJobId !== j.id;
|
||||||
|
setDragJobId(pickingUp ? j.id : null);
|
||||||
|
setDragOverColumn(null);
|
||||||
|
setAnnouncement(pickingUp ? t("kanbanPickedUpAnnouncement", { title: j.jobTitle }) : t("kanbanKeyboardMoveCancelled"));
|
||||||
|
} else if (event.key === "Enter" && dragJobId == null) {
|
||||||
|
event.preventDefault(); openJob(j.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => { if (dragJobId == null) openJob(j.id); }}
|
||||||
|
sx={{ cursor: "pointer", borderRadius: 1, "&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: 3 } }}
|
||||||
|
>
|
||||||
|
<Typography sx={{ pr: 4.5, fontWeight: 700, lineHeight: 1.3, overflowWrap: "anywhere" }}>
|
||||||
{j.jobTitle}
|
{j.jobTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
size="small"
|
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
||||||
aria-label={`Change status for ${j.jobTitle}`}
|
</Typography>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuJobId(j.id);
|
|
||||||
setMenuAnchor(e.currentTarget);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreHorizIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 0.75 }}>
|
<Box sx={{ mt: 0.75 }}>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={statusLabel(t, j.status)}
|
label={statusLabel(t, j.status)}
|
||||||
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
height: 22,
|
height: 22,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
backgroundColor: `color-mix(in srgb, ${cardColor} 14%, transparent)`,
|
borderColor: cardTone === "default" ? "divider" : cardColor,
|
||||||
color: cardColor,
|
color: cardTone === "default" ? "text.primary" : cardColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -358,7 +380,7 @@ export default function KanbanBoard() {
|
|||||||
key={tag}
|
key={tag}
|
||||||
size="small"
|
size="small"
|
||||||
label={tag}
|
label={tag}
|
||||||
sx={{ height: 22, backgroundColor: palette.action.hover, color: "primary.main", fontWeight: 700 }}
|
sx={{ height: 22, backgroundColor: "action.hover", color: "text.secondary", fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -372,25 +394,34 @@ export default function KanbanBoard() {
|
|||||||
sx={{
|
sx={{
|
||||||
height: 24,
|
height: 24,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
backgroundColor: `color-mix(in srgb, ${palette[pill.tone].main} 14%, transparent)`,
|
backgroundColor: "action.hover",
|
||||||
color: palette[pill.tone].main,
|
color: palette[pill.tone].main,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{j.daysSince != null ? (
|
{j.daysSince != null ? (
|
||||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`${t("jobTableQuickStatus")}: ${j.jobTitle}`}
|
||||||
|
onClick={(e) => { setMenuJobId(j.id); setMenuAnchor(e.currentTarget); }}
|
||||||
|
sx={{ position: "absolute", top: 5, right: 5, minWidth: { xs: 44, sm: 36 }, minHeight: { xs: 44, sm: 36 } }}
|
||||||
|
>
|
||||||
|
<MoreHorizIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{list.length === 0 && (
|
{list.length === 0 && (
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary", py: 2, px: 0.5 }}>
|
||||||
{t("kanbanDropHere")}
|
{dragJobId !== null && droppable ? t("kanbanDropHere") : t("kanbanEmptyColumn")}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1541,7 +1541,11 @@ export const translations = {
|
|||||||
adminUsersSafetyNotice: "The final administrator is protected. Removing your own administrator role requires explicit confirmation.",
|
adminUsersSafetyNotice: "The final administrator is protected. Removing your own administrator role requires explicit confirmation.",
|
||||||
adminUsersPassword: "Password",
|
adminUsersPassword: "Password",
|
||||||
kanbanHint: "Drag cards between columns to move a job forward. Use the card menu to set an exact stage.",
|
kanbanHint: "Drag cards between columns to move a job forward. Use the card menu to set an exact stage.",
|
||||||
|
kanbanSeparateViews: "The board shows all tracked jobs. Table filters do not apply here yet.",
|
||||||
kanbanDropHere: "Drop here",
|
kanbanDropHere: "Drop here",
|
||||||
|
kanbanEmptyColumn: "No jobs in this group yet.",
|
||||||
|
kanbanColumnLabel: "{label} column",
|
||||||
|
kanbanColumnNotDropTarget: "{label} column, not a drop target",
|
||||||
kanbanGroupOther: "Other",
|
kanbanGroupOther: "Other",
|
||||||
kanbanAppliedAgo: "Applied {days}d ago",
|
kanbanAppliedAgo: "Applied {days}d ago",
|
||||||
kanbanFollowUpNow: "Follow up now",
|
kanbanFollowUpNow: "Follow up now",
|
||||||
@@ -1554,7 +1558,7 @@ export const translations = {
|
|||||||
kanbanKeyboardMoveCancelled: "Keyboard move cancelled.",
|
kanbanKeyboardMoveCancelled: "Keyboard move cancelled.",
|
||||||
kanbanInvalidDropTarget: "{status} is not a valid drop target.",
|
kanbanInvalidDropTarget: "{status} is not a valid drop target.",
|
||||||
kanbanCardPickedUpLabel: "{title}, {status}. Picked up; focus a column and press Enter to move.",
|
kanbanCardPickedUpLabel: "{title}, {status}. Picked up; focus a column and press Enter to move.",
|
||||||
kanbanCardPickUpLabel: "{title}, {status}. Press Space to pick up.",
|
kanbanCardPickUpLabel: "{title}, {status}. Press Space to pick up or Enter to open.",
|
||||||
kanbanPickedUpAnnouncement: "{title} picked up. Focus a column and press Enter to move.",
|
kanbanPickedUpAnnouncement: "{title} picked up. Focus a column and press Enter to move.",
|
||||||
kanbanDragStartedAnnouncement: "{title} picked up.",
|
kanbanDragStartedAnnouncement: "{title} picked up.",
|
||||||
adminSystemTitle: "System status",
|
adminSystemTitle: "System status",
|
||||||
@@ -4003,7 +4007,11 @@ export const translations = {
|
|||||||
adminUsersSafetyNotice: "Den siste administratoren er beskyttet. Fjerning av din egen administratorrolle krever uttrykkelig bekreftelse.",
|
adminUsersSafetyNotice: "Den siste administratoren er beskyttet. Fjerning av din egen administratorrolle krever uttrykkelig bekreftelse.",
|
||||||
adminUsersPassword: "Passord",
|
adminUsersPassword: "Passord",
|
||||||
kanbanHint: "Dra kort mellom kolonnene for å flytte en jobb videre. Bruk kortmenyen for å sette et eksakt trinn.",
|
kanbanHint: "Dra kort mellom kolonnene for å flytte en jobb videre. Bruk kortmenyen for å sette et eksakt trinn.",
|
||||||
|
kanbanSeparateViews: "Tavlen viser alle lagrede jobber. Tabellfiltre gjelder ikke her ennå.",
|
||||||
kanbanDropHere: "Slipp her",
|
kanbanDropHere: "Slipp her",
|
||||||
|
kanbanEmptyColumn: "Ingen jobber i denne gruppen ennå.",
|
||||||
|
kanbanColumnLabel: "Kolonne: {label}",
|
||||||
|
kanbanColumnNotDropTarget: "Kolonne: {label}, ikke et slippmål",
|
||||||
kanbanGroupOther: "Andre",
|
kanbanGroupOther: "Andre",
|
||||||
kanbanAppliedAgo: "Søkt for {days}d siden",
|
kanbanAppliedAgo: "Søkt for {days}d siden",
|
||||||
kanbanFollowUpNow: "Følg opp nå",
|
kanbanFollowUpNow: "Følg opp nå",
|
||||||
@@ -4016,7 +4024,7 @@ export const translations = {
|
|||||||
kanbanKeyboardMoveCancelled: "Tastaturflytting avbrutt.",
|
kanbanKeyboardMoveCancelled: "Tastaturflytting avbrutt.",
|
||||||
kanbanInvalidDropTarget: "{status} er ikke et gyldig mål.",
|
kanbanInvalidDropTarget: "{status} er ikke et gyldig mål.",
|
||||||
kanbanCardPickedUpLabel: "{title}, {status}. Løftet; fokuser en kolonne og trykk Enter for å flytte.",
|
kanbanCardPickedUpLabel: "{title}, {status}. Løftet; fokuser en kolonne og trykk Enter for å flytte.",
|
||||||
kanbanCardPickUpLabel: "{title}, {status}. Trykk mellomrom for å løfte.",
|
kanbanCardPickUpLabel: "{title}, {status}. Trykk mellomrom for å løfte eller Enter for å åpne.",
|
||||||
kanbanPickedUpAnnouncement: "{title} er løftet. Fokuser en kolonne og trykk Enter for å flytte.",
|
kanbanPickedUpAnnouncement: "{title} er løftet. Fokuser en kolonne og trykk Enter for å flytte.",
|
||||||
kanbanDragStartedAnnouncement: "{title} er løftet.",
|
kanbanDragStartedAnnouncement: "{title} er løftet.",
|
||||||
adminSystemTitle: "Systemstatus",
|
adminSystemTitle: "Systemstatus",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ToastProvider } from './toast';
|
|||||||
import { I18nProvider } from './i18n/I18nProvider';
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { JobApplication } from './types';
|
import { JobApplication } from './types';
|
||||||
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
jest.mock('./api', () => ({
|
jest.mock('./api', () => ({
|
||||||
api: {
|
api: {
|
||||||
@@ -37,12 +38,22 @@ function renderBoard() {
|
|||||||
return render(
|
return render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<KanbanBoard />
|
<MemoryRouter initialEntries={['/kanban?source=board']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/kanban" element={<KanbanBoard />} />
|
||||||
|
<Route path="/jobs/:id" element={<div role="main">Job workspace <RouteState /></div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteState() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <span>{String((location.state as { from?: string } | null)?.from)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@@ -127,7 +138,7 @@ test('dropping onto a group applies that group entry stage', async () => {
|
|||||||
const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!;
|
const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!;
|
||||||
|
|
||||||
fireEvent.dragStart(card.closest('.MuiCard-root')!);
|
fireEvent.dragStart(card.closest('.MuiCard-root')!);
|
||||||
expect(card.closest('.MuiCard-root')).toHaveAttribute('aria-pressed', 'true');
|
expect(card.closest('.MuiCard-root')!.querySelector('[role="button"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
fireEvent.dragEnter(column);
|
fireEvent.dragEnter(column);
|
||||||
expect(column).toHaveAttribute('data-drop-state', 'active');
|
expect(column).toHaveAttribute('data-drop-state', 'active');
|
||||||
fireEvent.dragOver(column);
|
fireEvent.dragOver(column);
|
||||||
@@ -142,8 +153,9 @@ test('keyboard pickup and drop moves a card to a valid column', async () => {
|
|||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
const card = (await screen.findByText('Keyboard Role')).closest('.MuiCard-root')!;
|
const card = (await screen.findByText('Keyboard Role')).closest('.MuiCard-root')!;
|
||||||
fireEvent.keyDown(card, { key: ' ' });
|
const control = card.querySelector('[role="button"]')!;
|
||||||
expect(card).toHaveAttribute('aria-pressed', 'true');
|
fireEvent.keyDown(control, { key: ' ' });
|
||||||
|
expect(control).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
const column = screen.getByRole('group', { name: 'Active column' });
|
const column = screen.getByRole('group', { name: 'Active column' });
|
||||||
fireEvent.focus(column);
|
fireEvent.focus(column);
|
||||||
@@ -174,7 +186,7 @@ test('the card menu offers precise stages grouped by section', async () => {
|
|||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
await screen.findByText('Saved Role');
|
await screen.findByText('Saved Role');
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Change status for Saved Role' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Quick status: Saved Role' }));
|
||||||
|
|
||||||
const menu = await screen.findByRole('menu');
|
const menu = await screen.findByRole('menu');
|
||||||
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
|
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
|
||||||
@@ -183,3 +195,46 @@ test('the card menu offers precise stages grouped by section', async () => {
|
|||||||
// The job's own current stage is not offered as a target.
|
// The job's own current stage is not offered as a target.
|
||||||
expect(within(menu).queryByText(/Set status: Saved/)).not.toBeInTheDocument();
|
expect(within(menu).queryByText(/Set status: Saved/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('full-board population keeps jobs beyond the table page and offers a separate table link', async () => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data: Array.from({ length: 27 }, (_, index) => job(index + 1, `Role ${index}`, index === 26 ? 'Take-home assignment' : 'Saved')) } as any);
|
||||||
|
renderBoard();
|
||||||
|
expect(await screen.findByText('Role 26')).toBeVisible();
|
||||||
|
expect(screen.getAllByRole('button', { name: /Press Space to pick up/ })).toHaveLength(27);
|
||||||
|
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/board');
|
||||||
|
expect(screen.getByRole('link', { name: /Job Applications/ })).toHaveAttribute('href', '/jobs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard Escape cancels movement without a write and successful movement restores card focus', async () => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data: [job(11, 'Keyboard Role', 'Saved')] } as any);
|
||||||
|
renderBoard();
|
||||||
|
const control = await screen.findByRole('button', { name: /^Keyboard Role.*Press Space to pick up/ });
|
||||||
|
fireEvent.keyDown(control, { key: ' ' });
|
||||||
|
fireEvent.keyDown(control, { key: 'Escape' });
|
||||||
|
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||||
|
fireEvent.keyDown(control, { key: ' ' });
|
||||||
|
const column = screen.getByRole('group', { name: 'Active column' });
|
||||||
|
fireEvent.focus(column);
|
||||||
|
fireEvent.keyDown(column, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/11/status', { status: 'Applied' }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /^Keyboard Role/ })).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a card preserves its board return route and does not mutate status', async () => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data: [job(8, 'Open Role', 'Saved')] } as any);
|
||||||
|
renderBoard();
|
||||||
|
fireEvent.keyDown(await screen.findByRole('button', { name: /^Open Role.*Press Space to pick up/ }), { key: 'Enter' });
|
||||||
|
expect(await screen.findByText('Job workspace')).toBeVisible();
|
||||||
|
expect(screen.getByText('/kanban?source=board')).toBeVisible();
|
||||||
|
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard activation of the exact-status menu does not open the job', async () => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data: [job(9, 'Menu Role', 'Saved')] } as any);
|
||||||
|
renderBoard();
|
||||||
|
const menuButton = await screen.findByRole('button', { name: 'Quick status: Menu Role' });
|
||||||
|
fireEvent.keyDown(menuButton, { key: 'Enter' });
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
expect(await screen.findByRole('menu')).toBeVisible();
|
||||||
|
expect(screen.queryByText('Job workspace')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||