feat(redesign): migrate Jobs Board presentation (P5-04a)
This commit is contained in:
@@ -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.goto("/kanban");
|
||||
await expect(page.getByText("Synthetic platform engineer")).toBeVisible();
|
||||
const column = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]");
|
||||
expect(await column.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe("rgb(245, 242, 250)");
|
||||
const column = page.getByRole("group", { name: "Not Applied column" });
|
||||
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 });
|
||||
|
||||
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 expect(card).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(cardControl).toHaveAttribute("aria-pressed", "true");
|
||||
const active = page.getByRole("group", { name: "Active column" });
|
||||
await expect(active).toHaveAttribute("data-drop-state", "valid");
|
||||
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 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]");
|
||||
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 });
|
||||
const lightCard = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
||||
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 expect.poll(() => moveRequests).toBe(2);
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ for (const mode of ["light", "dark"] as const) {
|
||||
await keyboardFocus(page, dialog.getByRole("textbox"));
|
||||
await page.keyboard.press("Tab");
|
||||
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 expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
@@ -40,19 +42,6 @@ type Status = PipelineStatus;
|
||||
// belong to no group, and only appears when something is actually in it.
|
||||
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
|
||||
// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
|
||||
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)
|
||||
// -- 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.
|
||||
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) {
|
||||
const due = job.followUpAt ? new Date(job.followUpAt) : null;
|
||||
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" };
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
export default function KanbanBoard() {
|
||||
const theme = useTheme();
|
||||
const palette = theme.vars?.palette ?? theme.palette;
|
||||
const customShadows = (theme.vars as any)?.customShadows ?? (theme as any).customShadows;
|
||||
const { t } = useI18n();
|
||||
const { t, language } = 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 [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
|
||||
const [moveError, setMoveError] = useState("");
|
||||
@@ -109,6 +101,23 @@ export default function KanbanBoard() {
|
||||
|
||||
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 map = new Map<ColumnKey, JobApplication[]>();
|
||||
PIPELINE_GROUPS.forEach((g) => map.set(g.key, []));
|
||||
@@ -142,11 +151,13 @@ export default function KanbanBoard() {
|
||||
setMoveError("");
|
||||
try {
|
||||
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
|
||||
focusAfterMoveRef.current = movingJobId;
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
|
||||
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
||||
} catch (error: any) {
|
||||
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
||||
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
|
||||
document.querySelector<HTMLElement>(`[data-kanban-card-id="${movingJobId}"] [role="button"]`)?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -154,11 +165,13 @@ export default function KanbanBoard() {
|
||||
setMoveError("");
|
||||
try {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
focusAfterMoveRef.current = id;
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
||||
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
||||
} catch (error: any) {
|
||||
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
||||
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
|
||||
document.querySelector<HTMLElement>(`[data-kanban-card-id="${id}"] [role="button"]`)?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,9 +193,16 @@ export default function KanbanBoard() {
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
||||
{t("kanbanHint")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 1.5, flexDirection: { xs: "column", sm: "row" }, mb: 2 }}>
|
||||
<Box>
|
||||
<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
|
||||
loading={jobsResource.loading}
|
||||
@@ -195,23 +215,23 @@ export default function KanbanBoard() {
|
||||
{!jobsResource.loading && !jobsResource.error ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", md: "grid" },
|
||||
display: { xs: "flex", lg: "grid" },
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
minWidth: 0,
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
gridTemplateColumns: { lg: `repeat(${columns.length}, minmax(0, 1fr))` },
|
||||
gap: 1.5,
|
||||
alignItems: "start",
|
||||
overflowX: { xs: "auto", md: "visible" },
|
||||
scrollSnapType: { xs: "x mandatory", md: "none" },
|
||||
pb: { xs: 1, md: 0 },
|
||||
overflowX: { xs: "auto", lg: "visible" },
|
||||
scrollSnapType: { xs: "x mandatory", lg: "none" },
|
||||
pb: { xs: 1, lg: 0 },
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
{columns.map(({ key, label, droppable }) => {
|
||||
const list = groups.get(key) ?? [];
|
||||
// Colour the column by its entry stage so the header dot still carries meaning.
|
||||
const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : palette.text.disabled;
|
||||
const tone = droppable ? statusTone(GROUP_ENTRY_STATUS[key as PipelineGroup]) : "default";
|
||||
const c = tone === "default" ? palette.text.secondary : palette[tone].main;
|
||||
const isDragActive = dragJobId !== null;
|
||||
const isActiveTarget = dragOverColumn === key;
|
||||
return (
|
||||
@@ -219,7 +239,7 @@ export default function KanbanBoard() {
|
||||
key={key}
|
||||
component="section"
|
||||
role="group"
|
||||
aria-label={`${label} column${droppable ? "" : ", not a drop target"}`}
|
||||
aria-label={t(droppable ? "kanbanColumnLabel" : "kanbanColumnNotDropTarget", { label })}
|
||||
aria-disabled={isDragActive && !droppable ? true : undefined}
|
||||
tabIndex={isDragActive ? 0 : -1}
|
||||
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(); }}
|
||||
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
p: 1.25,
|
||||
borderRadius: 2.5,
|
||||
minHeight: 220,
|
||||
flex: { xs: "0 0 85vw", md: "none" },
|
||||
scrollSnapAlign: { xs: "start", md: "none" },
|
||||
border: `1px ${isDragActive ? "dashed" : "solid"} ${isActiveTarget ? palette.primary.main : palette.divider}`,
|
||||
outline: isActiveTarget ? `2px solid ${droppable ? palette.primary.main : palette.error.main}` : "none",
|
||||
flex: { xs: "0 0 min(85vw, 340px)", lg: "none" },
|
||||
scrollSnapAlign: { xs: "start", lg: "none" },
|
||||
border: "1px solid",
|
||||
borderColor: isActiveTarget ? (droppable ? "primary.main" : "error.main") : "divider",
|
||||
outline: isActiveTarget ? "2px solid" : "none",
|
||||
outlineColor: droppable ? "primary.main" : "error.main",
|
||||
outlineOffset: 2,
|
||||
opacity: isDragActive && !droppable ? 0.65 : 1,
|
||||
backgroundColor: isActiveTarget && droppable ? palette.action.hover : palette.grey[100],
|
||||
transition: "background-color .15s, border-color .15s, outline-color .15s, opacity .15s",
|
||||
"&:focus-visible": { boxShadow: customShadows?.focus },
|
||||
backgroundColor: isActiveTarget && droppable ? "action.hover" : "background.paper",
|
||||
transition: "background-color .15s, border-color .15s, outline-color .15s",
|
||||
"&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<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={{ 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}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -267,7 +288,7 @@ export default function KanbanBoard() {
|
||||
px: 0.75,
|
||||
py: 0.15,
|
||||
borderRadius: 999,
|
||||
backgroundColor: palette.grey[300],
|
||||
backgroundColor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
{list.map((j) => {
|
||||
const pill = cardPill(j, t);
|
||||
const pill = cardPill(j, t, language);
|
||||
const tags = parseTags(j.tags);
|
||||
// The column is a group, so each card names its own stage — grouping collapses
|
||||
// the columns, not the information.
|
||||
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 (
|
||||
<Card
|
||||
key={j.id}
|
||||
component="article"
|
||||
data-kanban-card-id={j.id}
|
||||
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 })); }}
|
||||
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 2.5,
|
||||
borderLeft: `4px solid ${cardColor}`,
|
||||
transition: "box-shadow .15s, transform .15s",
|
||||
outline: dragJobId === j.id ? `2px solid ${palette.primary.main}` : "none",
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
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,
|
||||
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
|
||||
"&:focus-visible": { boxShadow: customShadows?.focus },
|
||||
"&:hover": { borderColor: "border.strong", backgroundColor: "action.hover" },
|
||||
"&:active": { cursor: "grabbing" },
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
|
||||
<CardContent sx={{ position: "relative", p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||
<Box
|
||||
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}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Change status for ${j.jobTitle}`}
|
||||
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>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={statusLabel(t, j.status)}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 22,
|
||||
fontWeight: 700,
|
||||
backgroundColor: `color-mix(in srgb, ${cardColor} 14%, transparent)`,
|
||||
color: cardColor,
|
||||
borderColor: cardTone === "default" ? "divider" : cardColor,
|
||||
color: cardTone === "default" ? "text.primary" : cardColor,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -358,7 +380,7 @@ export default function KanbanBoard() {
|
||||
key={tag}
|
||||
size="small"
|
||||
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>
|
||||
@@ -372,25 +394,34 @@ export default function KanbanBoard() {
|
||||
sx={{
|
||||
height: 24,
|
||||
fontWeight: 700,
|
||||
backgroundColor: `color-mix(in srgb, ${palette[pill.tone].main} 14%, transparent)`,
|
||||
backgroundColor: "action.hover",
|
||||
color: palette[pill.tone].main,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{j.daysSince != null ? (
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
) : null}
|
||||
{j.daysSince != null ? (
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
) : 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>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
||||
{t("kanbanDropHere")}
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 2, px: 0.5 }}>
|
||||
{dragJobId !== null && droppable ? t("kanbanDropHere") : t("kanbanEmptyColumn")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1541,7 +1541,11 @@ export const translations = {
|
||||
adminUsersSafetyNotice: "The final administrator is protected. Removing your own administrator role requires explicit confirmation.",
|
||||
adminUsersPassword: "Password",
|
||||
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",
|
||||
kanbanEmptyColumn: "No jobs in this group yet.",
|
||||
kanbanColumnLabel: "{label} column",
|
||||
kanbanColumnNotDropTarget: "{label} column, not a drop target",
|
||||
kanbanGroupOther: "Other",
|
||||
kanbanAppliedAgo: "Applied {days}d ago",
|
||||
kanbanFollowUpNow: "Follow up now",
|
||||
@@ -1554,7 +1558,7 @@ export const translations = {
|
||||
kanbanKeyboardMoveCancelled: "Keyboard move cancelled.",
|
||||
kanbanInvalidDropTarget: "{status} is not a valid drop target.",
|
||||
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.",
|
||||
kanbanDragStartedAnnouncement: "{title} picked up.",
|
||||
adminSystemTitle: "System status",
|
||||
@@ -4003,7 +4007,11 @@ export const translations = {
|
||||
adminUsersSafetyNotice: "Den siste administratoren er beskyttet. Fjerning av din egen administratorrolle krever uttrykkelig bekreftelse.",
|
||||
adminUsersPassword: "Passord",
|
||||
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",
|
||||
kanbanEmptyColumn: "Ingen jobber i denne gruppen ennå.",
|
||||
kanbanColumnLabel: "Kolonne: {label}",
|
||||
kanbanColumnNotDropTarget: "Kolonne: {label}, ikke et slippmål",
|
||||
kanbanGroupOther: "Andre",
|
||||
kanbanAppliedAgo: "Søkt for {days}d siden",
|
||||
kanbanFollowUpNow: "Følg opp nå",
|
||||
@@ -4016,7 +4024,7 @@ export const translations = {
|
||||
kanbanKeyboardMoveCancelled: "Tastaturflytting avbrutt.",
|
||||
kanbanInvalidDropTarget: "{status} er ikke et gyldig mål.",
|
||||
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.",
|
||||
kanbanDragStartedAnnouncement: "{title} er løftet.",
|
||||
adminSystemTitle: "Systemstatus",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { JobApplication } from './types';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -37,12 +38,22 @@ function renderBoard() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<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>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function RouteState() {
|
||||
const location = useLocation();
|
||||
return <span>{String((location.state as { from?: string } | null)?.from)}</span>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
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!;
|
||||
|
||||
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);
|
||||
expect(column).toHaveAttribute('data-drop-state', 'active');
|
||||
fireEvent.dragOver(column);
|
||||
@@ -142,8 +153,9 @@ test('keyboard pickup and drop moves a card to a valid column', async () => {
|
||||
renderBoard();
|
||||
|
||||
const card = (await screen.findByText('Keyboard Role')).closest('.MuiCard-root')!;
|
||||
fireEvent.keyDown(card, { key: ' ' });
|
||||
expect(card).toHaveAttribute('aria-pressed', 'true');
|
||||
const control = card.querySelector('[role="button"]')!;
|
||||
fireEvent.keyDown(control, { key: ' ' });
|
||||
expect(control).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const column = screen.getByRole('group', { name: 'Active column' });
|
||||
fireEvent.focus(column);
|
||||
@@ -174,7 +186,7 @@ test('the card menu offers precise stages grouped by section', async () => {
|
||||
renderBoard();
|
||||
|
||||
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');
|
||||
// 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.
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user