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
+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 }}>