feat(applications): consolidate row actions into keyboard menus

This commit is contained in:
cesnimda
2026-11-10 23:04:08 +01:00
parent 70ddee8d3d
commit 86c589a0e0
4 changed files with 84 additions and 43 deletions
+12 -5
View File
@@ -90,6 +90,7 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await expect(density).toHaveText(t.densityComfortable);
await expect(main.getByRole("columnheader", { name: t.jobTableRole, exact: true })).toHaveAttribute("aria-sort", "ascending");
await capture(page, info, `jobs-top-desktop-${suffix}`);
await main.getByRole("button", { name: t.jobTableSelectionMode, exact: true }).click();
const rowText = await first.innerText();
const headers = await main.getByRole("columnheader").allTextContents();
const select = first.getByRole("checkbox");
@@ -157,13 +158,18 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
// Native row status request, listing-link isolation and workspace navigation.
await select.uncheck();
// Match by the translated action prefix; the long title remains in its accessible name.
const statusAction = first.getByRole("button", { name: new RegExp(`^${t.jobTableQuickStatus}:`) });
await keyboardFocus(page, statusAction);
await page.keyboard.press("Escape"); // Dismiss the existing tooltip, preserving focus.
const actions = first.getByRole("button", { name: new RegExp(`^${t.jobTableActions}:`) });
await keyboardFocus(page, actions);
await actions.press("Enter");
const actionsMenu = page.getByRole("menu");
await expect(actionsMenu).toBeVisible();
await page.keyboard.press("Escape");
await expect(actionsMenu).toBeHidden();
await expect(actions).toBeFocused();
const targets = await first.locator(".MuiIconButton-root,.MuiCheckbox-root").evaluateAll(nodes => nodes.map(node => ({ width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height })));
expect(targets.every(target => target.width >= 32 && target.height >= 32)).toBe(true);
await capture(page, info, `row-action-focus-${suffix}`);
await statusAction.press("Enter");
await actions.press("Enter");
const changed = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/status` && r.request().method() === "PATCH");
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview), exact: true }).click();
expect((await changed).status()).toBe(204);
@@ -210,7 +216,8 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
expect(JSON.parse((await page.evaluate(() => localStorage.getItem("jobColumns")))!).jobUrl).toBe(false);
// Real confirm/soft-delete and the same renderer's existing Trash restore path.
await first.getByRole("button", { name: new RegExp(`^${t.jobTableSoftDelete}:`) }).click();
await actions.click();
await actionsMenu.getByRole("menuitem", { name: t.jobTableSoftDelete, exact: true }).click();
const deleted = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}` && r.request().method() === "DELETE");
await page.getByRole("dialog").getByRole("button", { name: t.jobTableMove, exact: true }).click();
expect((await deleted).status()).toBe(204);
+45 -26
View File
@@ -1,4 +1,5 @@
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import {
@@ -6,6 +7,7 @@ import {
Button,
Checkbox,
Chip,
Divider,
FormControl,
FormControlLabel,
IconButton,
@@ -29,9 +31,6 @@ import {
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha } from "@mui/material/styles";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import RestoreFromTrashOutlinedIcon from "@mui/icons-material/RestoreFromTrashOutlined";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
import SearchIcon from "@mui/icons-material/Search";
@@ -180,9 +179,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [companyFilterId, setCompanyFilterId] = useState<number | "All">(routeQuery.companyId ?? "All");
const [editJobId, setEditJobId] = useState<number | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
const [rowMenu, setRowMenu] = useState<{ id: number; anchor: HTMLButtonElement; mobile: boolean; mode: "jobs" | "trash" } | null>(null);
const rowMenuId = useId();
const [bulkStatusAnchor, setBulkStatusAnchor] = useState<null | HTMLElement>(null);
const [statusJobId, setStatusJobId] = useState<number | null>(null);
const [sortBy, setSortBy] = useState<JobSortKey>(routeQuery.sortBy);
const [sortDir, setSortDir] = useState<"asc" | "desc">(routeQuery.sortDir);
@@ -347,6 +346,33 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
};
const filteredJobs = jobs;
const menuJob = rowMenu?.anchor.isConnected && rowMenu.mobile === isMobile && rowMenu.mode === mode
&& !boardView && !jobsResource.loading && !jobsResource.error ? filteredJobs.find(job => job.id === rowMenu.id) : undefined;
const rowActions = (job: JobApplication) => {
const expanded = menuJob?.id === job.id;
const label = `${t("jobTableActions")}: ${job.jobTitle}`;
return (
<Tooltip title={t("jobTableActions")}>
<IconButton
aria-label={label}
aria-haspopup="menu"
aria-expanded={expanded}
aria-controls={expanded ? rowMenuId : undefined}
onClick={event => setRowMenu({ id: job.id, anchor: event.currentTarget, mobile: isMobile, mode })}
sx={{ flexShrink: 0, width: isMobile ? 44 : 32, height: isMobile ? 44 : 32, "@media (pointer: coarse)": { width: 44, height: 44 } }}
>
<MoreHorizIcon fontSize="small" />
</IconButton>
</Tooltip>
);
};
const runRowAction = (action: () => void) => {
// Return focus to the trigger before an action opens a dialog.
flushSync(() => setRowMenu(null));
rowMenu?.anchor.focus({ preventScroll: true });
action();
};
useEffect(() => {
const focusJobId = window.history.state?.jobListFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
@@ -741,7 +767,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
>
<Stack spacing="var(--app-list-gap)">
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: "1 1 180px" }}>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: "1 1 120px" }}>
{showSelection ? <Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> : null}
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: 16, lineHeight: 1.35, overflowWrap: "anywhere" }}>
@@ -752,7 +778,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Typography>
</Box>
</Box>
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 700, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
<Box sx={{ display: "flex", alignItems: "center", gap: 0.25, maxWidth: "100%", ml: "auto" }}>
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 700, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
{rowActions(job)}
</Box>
</Box>
{actionSignals.length > 0 && <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
@@ -807,15 +836,6 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
) : null}
<Box sx={{ display: "flex", gap: 0.5, "& .MuiIconButton-root": { width: 44, height: 44 } }}>
<Tooltip title={t("jobTableEdit")}><IconButton aria-label={t("jobTableEdit")} onClick={() => setEditJobId(job.id)}><EditOutlinedIcon /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton aria-label={t("jobTableQuickStatus")} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
<Tooltip title={t("jobTableRestore")}><IconButton aria-label={t("jobTableRestore")} onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon /></IconButton></Tooltip>
) : (
<Tooltip title={t("jobTableSoftDelete")}><IconButton color="error" aria-label={t("jobTableSoftDelete")} onClick={() => void softDelete(job)}><DeleteOutlineIcon /></IconButton></Tooltip>
)}
</Box>
</Stack>
</Box>
);
@@ -895,13 +915,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{columns.daysSince ? <TableCell>{job.daysSince ?? "—"}</TableCell> : null}
<TableCell>{job.deadline ? new Date(job.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"}</TableCell>
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
<TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5, whiteSpace: "nowrap" }}>
<Tooltip title={t("jobTableEdit")}><IconButton size="small" aria-label={`${t("jobTableEdit")}: ${job.jobTitle}`} onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" aria-label={`${t("jobTableQuickStatus")}: ${job.jobTitle}`} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" aria-label={`${t("jobTableRestore")}: ${job.jobTitle}`} onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" aria-label={`${t("jobTableSoftDelete")}: ${job.jobTitle}`} onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box>
</TableCell>
<TableCell align="right">{rowActions(job)}</TableCell>
</TableRow>
);
})}
@@ -916,8 +930,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Paper>}
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: statusLabel(t, status) })}</MenuItem>)}
<Menu anchorEl={rowMenu?.anchor} open={Boolean(menuJob)} onClose={() => setRowMenu(null)} slotProps={{ list: { id: rowMenuId, "aria-label": t("jobTableActions") } }}>
{menuJob && <MenuItem onClick={() => runRowAction(() => setEditJobId(menuJob.id))}>{t("jobTableEdit")}</MenuItem>}
{menuJob && (mode === "trash" || (includeDeleted && menuJob.isDeleted)
? <MenuItem onClick={() => runRowAction(() => void restore(menuJob.id))}>{t("jobTableRestore")}</MenuItem>
: <MenuItem sx={{ color: "error.main" }} onClick={() => runRowAction(() => void softDelete(menuJob))}>{t("jobTableSoftDelete")}</MenuItem>)}
{menuJob && <Divider />}
{menuJob && statusOptions.map((status) => <MenuItem key={status} onClick={() => runRowAction(() => void setStatusQuick(menuJob.id, status))}>{t("jobTableSetStatus", { status: statusLabel(t, status) })}</MenuItem>)}
</Menu>
</Box>
);
+24 -9
View File
@@ -58,25 +58,28 @@ test("ordinary applications hide checkboxes until explicit selection mode and di
expect(screen.getByLabelText("Current route")).toHaveTextContent("/jobs?q=platform");
});
test("row status/edit/delete controls retain selection and do not open the workspace", async () => {
test("row action menu retains selection and does not open the workspace", async () => {
show();
const row = await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(screen.getByRole("button", { name: t.jobTableSelectionMode }));
fireEvent.click(within(row).getByRole("checkbox"));
fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableEdit}: ${job.jobTitle}` }));
const actions = within(row).getByRole("button", { name: `${t.jobTableActions}: ${job.jobTitle}` });
fireEvent.click(actions);
fireEvent.click(screen.getByRole("menuitem", { name: t.jobTableEdit }));
expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible();
fireEvent.click(screen.getByText("Close editor"));
expect(within(row).getByRole("checkbox")).toBeChecked();
fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableSoftDelete}: ${job.jobTitle}` }));
fireEvent.click(actions);
fireEvent.click(screen.getByRole("menuitem", { name: t.jobTableSoftDelete }));
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: /cancel/i }));
expect(mockedApi.delete).not.toHaveBeenCalled();
fireEvent.click(await within(row).findByRole("button", { name: `${t.jobTableQuickStatus}: ${job.jobTitle}` }));
fireEvent.click(actions);
fireEvent.click(await screen.findByRole("menuitem", { name: /Interview/ }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Interview" }));
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs$/);
});
test.each(["en", "nb"] as const)("%s mobile row actions remain direct, named and independent of row navigation", async language => {
test.each(["en", "nb"] as const)("%s mobile row menu is named and independent of row navigation", async language => {
const originalMedia = Object.getOwnPropertyDescriptor(window, "matchMedia");
Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: (query: string) => ({
matches: query.includes("max-width:767.95px"), media: query, onchange: null,
@@ -93,14 +96,17 @@ test.each(["en", "nb"] as const)("%s mobile row actions remain direct, named and
expect(roleTitle.compareDocumentPosition(employer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: copy.jobTableSelectionMode }));
fireEvent.click(within(row).getByRole("checkbox"));
fireEvent.click(within(row).getByRole("button", { name: copy.jobTableEdit }));
const actions = within(row).getByRole("button", { name: `${copy.jobTableActions}: ${job.jobTitle}` });
fireEvent.click(actions);
fireEvent.click(screen.getByRole("menuitem", { name: copy.jobTableEdit }));
expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible();
fireEvent.click(screen.getByText("Close editor"));
expect(within(row).getByRole("checkbox")).toBeChecked();
fireEvent.click(within(row).getByRole("button", { name: copy.jobTableSoftDelete }));
fireEvent.click(actions);
fireEvent.click(screen.getByRole("menuitem", { name: copy.jobTableSoftDelete }));
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: copy.cancel }));
expect(mockedApi.delete).not.toHaveBeenCalled();
fireEvent.click(await within(row).findByRole("button", { name: copy.jobTableQuickStatus }));
fireEvent.click(actions);
fireEvent.click(await screen.findByRole("menuitem", { name: copy.jobTableSetStatus.replace("{status}", copy.statusInterview) }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Interview" }));
expect(screen.getByLabelText("Current route")).toHaveTextContent("/jobs?q=platform");
@@ -140,6 +146,15 @@ test("Trash select-all and bulk restore use the original IDs without workspace n
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/trash$/);
});
test("Trash row menu restores the original ID and omits the delete action", async () => {
show("trash");
fireEvent.click(await screen.findByRole("button", { name: `${t.jobTableActions}: ${job.jobTitle}` }));
expect(screen.queryByRole("menuitem", { name: t.jobTableSoftDelete })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: t.jobTableRestore }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/42/restore"));
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/trash$/);
});
async function compact() {
fireEvent.mouseDown(screen.getByRole("combobox", { name: t.listDensity }));
fireEvent.click(await screen.findByRole("option", { name: t.densityCompact }));
@@ -204,7 +219,7 @@ test("Bokmål display localizes standard statuses without changing API values or
expect(within(row).getByText(new Date(standardJob.dateApplied).toLocaleDateString("nb-NO"))).toBeVisible();
expect(within(row).getByText(new Date(standardJob.deadline).toLocaleDateString("nb-NO"))).toBeVisible();
expect(screen.getByRole("combobox", { name: translations.nb.jobTableStatus })).toHaveTextContent("Custom review");
fireEvent.click(within(row).getByRole("button", { name: `${translations.nb.jobTableQuickStatus}: ${job.jobTitle}` }));
fireEvent.click(within(row).getByRole("button", { name: `${translations.nb.jobTableActions}: ${job.jobTitle}` }));
fireEvent.click(await screen.findByRole("menuitem", { name: translations.nb.jobTableSetStatus.replace("{status}", translations.nb.statusOffer) }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" }));
});
@@ -198,7 +198,7 @@ test('package-work workflow signals route all overview surfaces to the shared ta
<Route path="/jobs/:id" element={<LocationIndicator />} />
</>);
fireEvent.click(await screen.findByRole('button', { name: /build package/i }));
fireEvent.click(await screen.findByRole('button', { name: /prepare application/i }));
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/43?section=cv'));
dashboardRender.unmount();
@@ -208,7 +208,7 @@ test('package-work workflow signals route all overview surfaces to the shared ta
<Route path="/jobs/:id" element={<LocationIndicator />} />
</>);
fireEvent.click(await screen.findByRole('button', { name: /build package/i }));
fireEvent.click(await screen.findByRole('button', { name: /prepare application/i }));
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/43?section=cv'));
remindersRender.unmount();
@@ -218,7 +218,7 @@ test('package-work workflow signals route all overview surfaces to the shared ta
<Route path="/jobs/:id" element={<LocationIndicator />} />
</>);
fireEvent.click(await screen.findByRole('button', { name: /platform engineer — build package signal/i }));
fireEvent.click(await screen.findByRole('button', { name: /platform engineer — prepare application signal/i }));
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/43?section=cv'));
});