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 { expect, test, type Page } from "@playwright/test";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
const updateAuditEvidence = process.env.UPDATE_AUDIT_EVIDENCE === "1";
|
||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByLabel("Email").fill("e2e@example.test");
|
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("Work arrangement not provided by this source").first()).toBeVisible();
|
||||||
await expect(page.getByText(/Application deadline:/)).toBeVisible();
|
await expect(page.getByText(/Application deadline:/)).toBeVisible();
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
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.getByLabel("Sort results").click();
|
||||||
await page.getByRole("option", { name: "Title A–Z" }).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 expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
|
||||||
await page.getByRole("button", { name: "Search NAV" }).click();
|
await page.getByRole("button", { name: "Search NAV" }).click();
|
||||||
await expect(page.getByText("2 vacancies found")).toBeVisible();
|
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 page.getByRole("button", { name: "Save to tracker" }).first().click();
|
||||||
await expect(page.getByRole("dialog")).toBeVisible();
|
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 React, { useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -12,10 +13,10 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import ViewStateNotice from "./ViewStateNotice";
|
import ViewStateNotice from "./ViewStateNotice";
|
||||||
import { JobApplication } from "../types";
|
import { JobApplication } from "../types";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -40,12 +41,12 @@ type Status = PipelineStatus;
|
|||||||
type ColumnKey = PipelineGroup | "Other";
|
type ColumnKey = PipelineGroup | "Other";
|
||||||
|
|
||||||
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
||||||
error: (theme) => theme.palette.error.main,
|
error: (theme) => (theme.vars?.palette ?? theme.palette).error.main,
|
||||||
warning: (theme) => theme.palette.warning.main,
|
warning: (theme) => (theme.vars?.palette ?? theme.palette).warning.main,
|
||||||
success: (theme) => theme.palette.success.main,
|
success: (theme) => (theme.vars?.palette ?? theme.palette).success.main,
|
||||||
info: (theme) => alpha(theme.palette.primary.main, 0.95),
|
info: (theme) => (theme.vars?.palette ?? theme.palette).info.main,
|
||||||
primary: (theme) => theme.palette.primary.main,
|
primary: (theme) => (theme.vars?.palette ?? theme.palette).primary.main,
|
||||||
default: (theme) => theme.palette.primary.main,
|
default: (theme) => (theme.vars?.palette ?? theme.palette).primary.main,
|
||||||
};
|
};
|
||||||
|
|
||||||
function toneColor(theme: any, status: Status | "Other"): string {
|
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() {
|
export default function KanbanBoard() {
|
||||||
const theme = useTheme();
|
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 } = useI18n();
|
||||||
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
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 [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -129,15 +135,31 @@ export default function KanbanBoard() {
|
|||||||
// stages stay on the card menu.
|
// stages stay on the card menu.
|
||||||
const onDropTo = async (group: PipelineGroup) => {
|
const onDropTo = async (group: PipelineGroup) => {
|
||||||
if (!dragJobId) return;
|
if (!dragJobId) return;
|
||||||
|
const movingJobId = dragJobId;
|
||||||
const status = GROUP_ENTRY_STATUS[group];
|
const status = GROUP_ENTRY_STATUS[group];
|
||||||
setDragJobId(null);
|
setDragJobId(null);
|
||||||
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
|
setDragOverColumn(null);
|
||||||
jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
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) => {
|
const setStatus = async (id: number, status: Status) => {
|
||||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
setMoveError("");
|
||||||
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
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 ?? "");
|
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
||||||
@@ -157,7 +179,7 @@ export default function KanbanBoard() {
|
|||||||
}, [groups, t]);
|
}, [groups, t]);
|
||||||
|
|
||||||
return (
|
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 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
||||||
{t("kanbanHint")}
|
{t("kanbanHint")}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -174,22 +196,46 @@ export default function KanbanBoard() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: "flex", md: "grid" },
|
display: { xs: "flex", md: "grid" },
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
minWidth: 0,
|
||||||
gridTemplateColumns: { md: "repeat(3, 1fr)" },
|
gridTemplateColumns: { md: "repeat(3, 1fr)" },
|
||||||
gap: 2,
|
gap: 2,
|
||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
overflowX: { xs: "auto", md: "visible" },
|
overflowX: { xs: "auto", md: "visible" },
|
||||||
scrollSnapType: { xs: "x mandatory", md: "none" },
|
scrollSnapType: { xs: "x mandatory", md: "none" },
|
||||||
pb: { xs: 1, md: 0 },
|
pb: { xs: 1, md: 0 },
|
||||||
"-webkit-overflow-scrolling": "touch",
|
WebkitOverflowScrolling: "touch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map(({ key, label, droppable }) => {
|
{columns.map(({ key, label, droppable }) => {
|
||||||
const list = groups.get(key) ?? [];
|
const list = groups.get(key) ?? [];
|
||||||
// Colour the column by its entry stage so the header dot still carries meaning.
|
// 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 (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
key={key}
|
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(); }}
|
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
|
||||||
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -198,8 +244,13 @@ export default function KanbanBoard() {
|
|||||||
minHeight: 220,
|
minHeight: 220,
|
||||||
flex: { xs: "0 0 85vw", md: "none" },
|
flex: { xs: "0 0 85vw", md: "none" },
|
||||||
scrollSnapAlign: { xs: "start", md: "none" },
|
scrollSnapAlign: { xs: "start", md: "none" },
|
||||||
border: "none",
|
border: `1px ${isDragActive ? "dashed" : "solid"} ${isActiveTarget ? palette.primary.main : palette.divider}`,
|
||||||
backgroundColor: theme.palette.grey[100],
|
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 }}>
|
<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,
|
px: 0.75,
|
||||||
py: 0.15,
|
py: 0.15,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: theme.palette.grey[300],
|
backgroundColor: palette.grey[300],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||||
@@ -237,14 +288,32 @@ export default function KanbanBoard() {
|
|||||||
<Card
|
<Card
|
||||||
key={j.id}
|
key={j.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDragJobId(j.id)}
|
role="button"
|
||||||
onDragEnd={() => setDragJobId(null)}
|
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={{
|
sx={{
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
borderLeft: `4px solid ${cardColor}`,
|
borderLeft: `4px solid ${cardColor}`,
|
||||||
transition: "box-shadow .15s, transform .15s",
|
transition: "box-shadow .15s, transform .15s",
|
||||||
|
outline: dragJobId === j.id ? `2px solid ${palette.primary.main}` : "none",
|
||||||
|
outlineOffset: 2,
|
||||||
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
|
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
|
||||||
|
"&:focus-visible": { boxShadow: customShadows?.focus },
|
||||||
"&:active": { cursor: "grabbing" },
|
"&:active": { cursor: "grabbing" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -255,6 +324,7 @@ export default function KanbanBoard() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label={`Change status for ${j.jobTitle}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setMenuJobId(j.id);
|
setMenuJobId(j.id);
|
||||||
@@ -275,7 +345,7 @@ export default function KanbanBoard() {
|
|||||||
sx={{
|
sx={{
|
||||||
height: 22,
|
height: 22,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
backgroundColor: alpha(cardColor, 0.14),
|
backgroundColor: `color-mix(in srgb, ${cardColor} 14%, transparent)`,
|
||||||
color: cardColor,
|
color: cardColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -288,7 +358,7 @@ export default function KanbanBoard() {
|
|||||||
key={tag}
|
key={tag}
|
||||||
size="small"
|
size="small"
|
||||||
label={tag}
|
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>
|
</Box>
|
||||||
@@ -302,8 +372,8 @@ export default function KanbanBoard() {
|
|||||||
sx={{
|
sx={{
|
||||||
height: 24,
|
height: 24,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
backgroundColor: alpha(theme.palette[pill.tone].main, 0.14),
|
backgroundColor: `color-mix(in srgb, ${palette[pill.tone].main} 14%, transparent)`,
|
||||||
color: theme.palette[pill.tone].main,
|
color: palette[pill.tone].main,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -330,6 +400,11 @@ export default function KanbanBoard() {
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : 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. */}
|
{/* 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); }}>
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
||||||
{PIPELINE_GROUPS.flatMap((g) => {
|
{PIPELINE_GROUPS.flatMap((g) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ jest.mock('./api', () => ({
|
|||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
},
|
},
|
||||||
|
getApiErrorMessage: jest.fn((_error, fallback) => fallback),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// eslint-disable-next-line import/first
|
// 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');
|
await screen.findByText('Other');
|
||||||
// Custom statuses survive rather than being coerced into a canonical stage.
|
// Custom statuses survive rather than being coerced into a canonical stage.
|
||||||
expect(screen.getByText('Take-home assignment')).toBeInTheDocument();
|
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 () => {
|
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!;
|
const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!;
|
||||||
|
|
||||||
fireEvent.dragStart(card.closest('.MuiCard-root')!);
|
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.dragOver(column);
|
||||||
fireEvent.drop(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' }));
|
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 () => {
|
test('the card menu offers precise stages grouped by section', async () => {
|
||||||
mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any);
|
mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any);
|
||||||
|
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
await screen.findByText('Saved Role');
|
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');
|
const menu = await screen.findByRole('menu');
|
||||||
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
|
// 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 },
|
p: { xs: 2, sm: 3 },
|
||||||
bgcolor: "background.default",
|
bgcolor: "background.default",
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
|
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user