fix(kanban): honor theme and keyboard moves
CI and Deploy / test (pull_request) Successful in 4m45s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-10 11:12:49 +02:00
parent 3a99531e46
commit fb6f17e1f3
7 changed files with 247 additions and 27 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+4 -2
View File
@@ -1,6 +1,8 @@
import { expect, test, type Page } from "@playwright/test";
import path from "node:path";
const updateAuditEvidence = process.env.UPDATE_AUDIT_EVIDENCE === "1";
async function login(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill("e2e@example.test");
@@ -62,7 +64,7 @@ test("discovery remains usable across widths and opens the reviewed import", asy
await expect(page.getByText("Work arrangement not provided by this source").first()).toBeVisible();
await expect(page.getByText(/Application deadline:/)).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-375-light.png"), fullPage: true });
if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-375-light.png"), fullPage: true });
await page.getByLabel("Sort results").click();
await page.getByRole("option", { name: "Title AZ" }).click();
@@ -82,7 +84,7 @@ test("discovery remains usable across widths and opens the reviewed import", asy
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
await page.getByRole("button", { name: "Search NAV" }).click();
await expect(page.getByText("2 vacancies found")).toBeVisible();
await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-1440-dark.png"), fullPage: true });
if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-1440-dark.png"), fullPage: true });
await page.getByRole("button", { name: "Save to tracker" }).first().click();
await expect(page.getByRole("dialog")).toBeVisible();
+88
View File
@@ -0,0 +1,88 @@
import { expect, test, type Page } from "@playwright/test";
import path from "node:path";
const updateAuditEvidence = process.env.UPDATE_AUDIT_EVIDENCE === "1";
async function login(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill("e2e@example.test");
await page.getByLabel("Current password").fill("E2ePassword123!");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test("kanban columns use dark theme surfaces", async ({ page }) => {
let moveRequests = 0;
await page.addInitScript(() => {
window.localStorage.setItem("uiLanguage", "en");
window.localStorage.setItem("themeMode:anon", "dark");
});
await page.route("**/api/jobapplications/board", async (route) => {
await route.fulfill({ json: [{
id: 71,
jobTitle: "Synthetic platform engineer",
companyId: 1,
company: { id: 1, name: "Synthetic AS" },
status: "Saved",
dateApplied: null,
savedAt: "2026-08-10T10:00:00Z",
daysSince: null,
tags: "[\"C#\",\"Azure\"]",
}, {
id: 72,
jobTitle: "Custom status role",
companyId: 1,
company: { id: 1, name: "Synthetic AS" },
status: "Take-home assignment",
dateApplied: "2026-08-09T10:00:00Z",
savedAt: "2026-08-09T10:00:00Z",
daysSince: 1,
}] });
});
await page.route("**/api/jobapplications/71/status", async (route) => { moveRequests += 1; await route.fulfill({ json: {} }); });
await login(page);
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)");
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();
await page.keyboard.press("Space");
await expect(card).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");
await active.focus();
await page.keyboard.press("Enter");
await expect.poll(() => moveRequests).toBe(1);
await expect(page.getByText("Job moved to Applied.")).toBeAttached();
await page.setViewportSize({ width: 375, height: 812 });
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
const overflow = await page.evaluate(() => ({
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
offenders: Array.from(document.querySelectorAll("*")).map((element) => {
const rect = element.getBoundingClientRect();
return { tag: element.tagName, className: element.className?.toString().slice(0, 80), left: rect.left, right: rect.right, width: rect.width };
}).filter((element) => (element.right > window.innerWidth + 1 && element.right < window.innerWidth + 40) || element.left < -1).slice(0, 12),
}));
const pageScroll = await page.evaluate(() => {
document.documentElement.scrollLeft = 999;
const scrollLeft = document.documentElement.scrollLeft;
document.documentElement.scrollLeft = 0;
return scrollLeft;
});
expect(pageScroll, JSON.stringify(overflow)).toBe(0);
await page.evaluate(() => {
const userKey = window.localStorage.getItem("authUserKey") || "anon";
window.localStorage.setItem(`themeMode:${userKey}`, "light");
});
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)");
});
+99 -24
View File
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from "react";
import {
Alert,
Box,
Card,
CardContent,
@@ -12,10 +13,10 @@ import {
Paper,
Typography,
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import { useTheme } from "@mui/material/styles";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { api } from "../api";
import { api, getApiErrorMessage } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
@@ -40,12 +41,12 @@ type Status = PipelineStatus;
type ColumnKey = PipelineGroup | "Other";
const TONE_PALETTE: Record<string, (theme: any) => string> = {
error: (theme) => theme.palette.error.main,
warning: (theme) => theme.palette.warning.main,
success: (theme) => theme.palette.success.main,
info: (theme) => alpha(theme.palette.primary.main, 0.95),
primary: (theme) => theme.palette.primary.main,
default: (theme) => theme.palette.primary.main,
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 {
@@ -84,8 +85,13 @@ function cardPill(job: JobApplication, t: (key: any, params?: any) => string): {
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 [dragJobId, setDragJobId] = useState<number | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
const [moveError, setMoveError] = useState("");
const [announcement, setAnnouncement] = useState("");
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuJobId, setMenuJobId] = useState<number | null>(null);
@@ -129,15 +135,31 @@ export default function KanbanBoard() {
// stages stay on the card menu.
const onDropTo = async (group: PipelineGroup) => {
if (!dragJobId) return;
const movingJobId = dragJobId;
const status = GROUP_ENTRY_STATUS[group];
setDragJobId(null);
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
setDragOverColumn(null);
setMoveError("");
try {
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
setAnnouncement(`Job moved to ${statusLabel(t, status)}.`);
} catch (error: any) {
setMoveError(getApiErrorMessage(error, "Unable to move this job right now."));
setAnnouncement("Job move failed.");
}
};
const setStatus = async (id: number, status: Status) => {
await api.patch(`/jobapplications/${id}/status`, { status });
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
setMoveError("");
try {
await api.patch(`/jobapplications/${id}/status`, { status });
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
setAnnouncement(`Job moved to ${statusLabel(t, status)}.`);
} catch (error: any) {
setMoveError(getApiErrorMessage(error, "Unable to move this job right now."));
setAnnouncement("Job move failed.");
}
};
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
@@ -157,7 +179,7 @@ export default function KanbanBoard() {
}, [groups, t]);
return (
<Box sx={{ mt: 2 }}>
<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>
@@ -174,22 +196,46 @@ export default function KanbanBoard() {
<Box
sx={{
display: { xs: "flex", md: "grid" },
width: "100%",
maxWidth: "100%",
minWidth: 0,
gridTemplateColumns: { md: "repeat(3, 1fr)" },
gap: 2,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
scrollSnapType: { xs: "x mandatory", md: "none" },
pb: { xs: 1, md: 0 },
"-webkit-overflow-scrolling": "touch",
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]) : theme.palette.grey[500];
const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : palette.text.disabled;
const isDragActive = dragJobId !== null;
const isActiveTarget = dragOverColumn === key;
return (
<Paper
key={key}
component="section"
role="group"
aria-label={`${label} column${droppable ? "" : ", not a drop target"}`}
aria-disabled={isDragActive && !droppable ? true : undefined}
tabIndex={isDragActive ? 0 : -1}
data-drop-state={isActiveTarget ? (droppable ? "active" : "invalid") : isDragActive ? (droppable ? "valid" : "invalid") : "idle"}
onFocus={() => { if (isDragActive) setDragOverColumn(key); }}
onBlur={() => { if (dragOverColumn === key) setDragOverColumn(null); }}
onKeyDown={(event) => {
if (event.key === "Escape" && isDragActive) {
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled.");
} else if ((event.key === "Enter" || event.key === " ") && isDragActive) {
event.preventDefault();
if (droppable) void onDropTo(key as PipelineGroup);
else setAnnouncement(`${label} is not a valid drop target.`);
}
}}
onDragEnter={() => { if (isDragActive) setDragOverColumn(key); }}
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragOverColumn(null); }}
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
sx={{
@@ -198,8 +244,13 @@ export default function KanbanBoard() {
minHeight: 220,
flex: { xs: "0 0 85vw", md: "none" },
scrollSnapAlign: { xs: "start", md: "none" },
border: "none",
backgroundColor: theme.palette.grey[100],
border: `1px ${isDragActive ? "dashed" : "solid"} ${isActiveTarget ? palette.primary.main : palette.divider}`,
outline: isActiveTarget ? `2px solid ${droppable ? palette.primary.main : palette.error.main}` : "none",
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 },
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
@@ -216,7 +267,7 @@ export default function KanbanBoard() {
px: 0.75,
py: 0.15,
borderRadius: 999,
backgroundColor: theme.palette.grey[300],
backgroundColor: palette.grey[300],
}}
>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
@@ -237,14 +288,32 @@ export default function KanbanBoard() {
<Card
key={j.id}
draggable
onDragStart={() => setDragJobId(j.id)}
onDragEnd={() => setDragJobId(null)}
role="button"
tabIndex={0}
aria-pressed={dragJobId === j.id}
aria-label={`${j.jobTitle}, ${statusLabel(t, j.status)}. ${dragJobId === j.id ? "Picked up; focus a column and press Enter to move." : "Press Space to pick up."}`}
onKeyDown={(event) => {
if (event.key === "Escape" && dragJobId === j.id) {
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled.");
} else if (event.key === " ") {
event.preventDefault();
const pickingUp = dragJobId !== j.id;
setDragJobId(pickingUp ? j.id : null);
setDragOverColumn(null);
setAnnouncement(pickingUp ? `${j.jobTitle} picked up. Focus a column and press Enter to move.` : "Keyboard move cancelled.");
}
}}
onDragStart={() => { setDragJobId(j.id); setAnnouncement(`${j.jobTitle} picked up.`); }}
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",
outlineOffset: 2,
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
"&:focus-visible": { boxShadow: customShadows?.focus },
"&:active": { cursor: "grabbing" },
}}
>
@@ -255,6 +324,7 @@ export default function KanbanBoard() {
</Typography>
<IconButton
size="small"
aria-label={`Change status for ${j.jobTitle}`}
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
@@ -275,7 +345,7 @@ export default function KanbanBoard() {
sx={{
height: 22,
fontWeight: 700,
backgroundColor: alpha(cardColor, 0.14),
backgroundColor: `color-mix(in srgb, ${cardColor} 14%, transparent)`,
color: cardColor,
}}
/>
@@ -288,7 +358,7 @@ export default function KanbanBoard() {
key={tag}
size="small"
label={tag}
sx={{ height: 22, backgroundColor: alpha(theme.palette.primary.main, 0.12), color: "primary.main", fontWeight: 700 }}
sx={{ height: 22, backgroundColor: palette.action.hover, color: "primary.main", fontWeight: 700 }}
/>
))}
</Box>
@@ -302,8 +372,8 @@ export default function KanbanBoard() {
sx={{
height: 24,
fontWeight: 700,
backgroundColor: alpha(theme.palette[pill.tone].main, 0.14),
color: theme.palette[pill.tone].main,
backgroundColor: `color-mix(in srgb, ${palette[pill.tone].main} 14%, transparent)`,
color: palette[pill.tone].main,
}}
/>
</Box>
@@ -330,6 +400,11 @@ export default function KanbanBoard() {
</Box>
) : null}
{moveError ? <Alert severity="error" variant="outlined" sx={{ mt: 2, color: "text.primary" }} onClose={() => setMoveError("")}>{moveError}</Alert> : null}
<Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", p: 0, m: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: 0 }} aria-live="polite">
{announcement}
</Box>
{/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */}
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
{PIPELINE_GROUPS.flatMap((g) => {
@@ -11,6 +11,7 @@ jest.mock('./api', () => ({
get: jest.fn(),
patch: jest.fn(() => Promise.resolve({ data: {} })),
},
getApiErrorMessage: jest.fn((_error, fallback) => fallback),
}));
// eslint-disable-next-line import/first
@@ -86,6 +87,25 @@ test('a custom status gets an Other column that is not a drop target', async ()
await screen.findByText('Other');
// Custom statuses survive rather than being coerced into a canonical stage.
expect(screen.getByText('Take-home assignment')).toBeInTheDocument();
fireEvent.dragStart(screen.getByText('Odd Role').closest('.MuiCard-root')!);
const other = screen.getByRole('group', { name: 'Other column, not a drop target' });
expect(other).toHaveAttribute('aria-disabled', 'true');
expect(other).toHaveAttribute('data-drop-state', 'invalid');
});
test('loading and retryable error states replace the board', async () => {
let rejectLoad!: (reason: unknown) => void;
mockedApi.get.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectLoad = reject; }) as any);
renderBoard();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Not Applied column' })).not.toBeInTheDocument();
rejectLoad(new Error('offline'));
expect(await screen.findByText('Unable to load the kanban board')).toBeInTheDocument();
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeInTheDocument();
});
test('dropping onto a group applies that group entry stage', async () => {
@@ -97,6 +117,9 @@ 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');
fireEvent.dragEnter(column);
expect(column).toHaveAttribute('data-drop-state', 'active');
fireEvent.dragOver(column);
fireEvent.drop(column);
@@ -104,13 +127,44 @@ test('dropping onto a group applies that group entry stage', async () => {
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/7/status', { status: 'Applied' }));
});
test('keyboard pickup and drop moves a card to a valid column', async () => {
mockedApi.get.mockResolvedValue({ data: [job(11, 'Keyboard Role', 'Saved')] } as any);
renderBoard();
const card = (await screen.findByText('Keyboard Role')).closest('.MuiCard-root')!;
fireEvent.keyDown(card, { key: ' ' });
expect(card).toHaveAttribute('aria-pressed', 'true');
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' }));
expect(await screen.findByText('Job moved to Applied.')).toBeInTheDocument();
});
test('a failed move keeps the card and reports a retryable error', async () => {
mockedApi.get.mockResolvedValue({ data: [job(12, 'Failed Move Role', 'Saved')] } as any);
mockedApi.patch.mockRejectedValueOnce(new Error('offline'));
renderBoard();
const card = (await screen.findByText('Failed Move Role')).closest('.MuiCard-root')!;
const column = screen.getByRole('group', { name: 'Active column' });
fireEvent.dragStart(card);
fireEvent.dragOver(column);
fireEvent.drop(column);
expect(await screen.findByText('Unable to move this job right now.')).toBeInTheDocument();
expect(screen.getByText('Failed Move Role')).toBeInTheDocument();
});
test('the card menu offers precise stages grouped by section', async () => {
mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any);
renderBoard();
await screen.findByText('Saved Role');
fireEvent.click(screen.getByRole('button', { name: '' }) ?? screen.getAllByRole('button')[0]);
fireEvent.click(screen.getByRole('button', { name: 'Change status for Saved Role' }));
const menu = await screen.findByRole('menu');
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
+1
View File
@@ -489,6 +489,7 @@ export default function AppShell({
p: { xs: 2, sm: 3 },
bgcolor: "background.default",
minHeight: "100vh",
minWidth: 0,
}}
>
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>