feat: complete release readiness work #28
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -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 A–Z" }).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();
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
@@ -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) => {
|
||||
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