fix(kanban): honor theme and keyboard moves
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user