From 86c589a0e029728ce4b9f337495a950a9af70b00 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 23:04:08 +0100 Subject: [PATCH] feat(applications): consolidate row actions into keyboard menus --- job-tracker-ui/e2e/table-density.spec.ts | 17 +++-- job-tracker-ui/src/components/JobTable.tsx | 71 ++++++++++++------- job-tracker-ui/src/job-table-density.test.tsx | 33 ++++++--- .../src/workflow-trust-signals.test.tsx | 6 +- 4 files changed, 84 insertions(+), 43 deletions(-) diff --git a/job-tracker-ui/e2e/table-density.spec.ts b/job-tracker-ui/e2e/table-density.spec.ts index e36a24d3..bee98d33 100644 --- a/job-tracker-ui/e2e/table-density.spec.ts +++ b/job-tracker-ui/e2e/table-density.spec.ts @@ -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); diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 883aa218..1eeb1ded 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -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(routeQuery.companyId ?? "All"); const [editJobId, setEditJobId] = useState(null); const [reloadToken, setReloadToken] = useState(0); - const [statusAnchor, setStatusAnchor] = useState(null); + const [rowMenu, setRowMenu] = useState<{ id: number; anchor: HTMLButtonElement; mobile: boolean; mode: "jobs" | "trash" } | null>(null); + const rowMenuId = useId(); const [bulkStatusAnchor, setBulkStatusAnchor] = useState(null); - const [statusJobId, setStatusJobId] = useState(null); const [sortBy, setSortBy] = useState(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 ( + + 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 } }} + > + + + + ); + }; + + 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 > - + {showSelection ? toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> : null} @@ -752,7 +778,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - {columns.status ? : null} + + {columns.status ? : null} + {rowActions(job)} + {actionSignals.length > 0 && @@ -807,15 +836,6 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ) : null} - - setEditJobId(job.id)}> - { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> - {(mode === "trash" || (includeDeleted && job.isDeleted)) ? ( - void restore(job.id)}> - ) : ( - void softDelete(job)}> - )} - ); @@ -895,13 +915,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {columns.daysSince ? {job.daysSince ?? "—"} : null} {job.deadline ? new Date(job.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"} {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} - - - setEditJobId(job.id)}> - { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> - {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} - - + {rowActions(job)} ); })} @@ -916,8 +930,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col } setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} /> - { setStatusAnchor(null); setStatusJobId(null); }}> - {statusOptions.map((status) => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: statusLabel(t, status) })})} + setRowMenu(null)} slotProps={{ list: { id: rowMenuId, "aria-label": t("jobTableActions") } }}> + {menuJob && runRowAction(() => setEditJobId(menuJob.id))}>{t("jobTableEdit")}} + {menuJob && (mode === "trash" || (includeDeleted && menuJob.isDeleted) + ? runRowAction(() => void restore(menuJob.id))}>{t("jobTableRestore")} + : runRowAction(() => void softDelete(menuJob))}>{t("jobTableSoftDelete")})} + {menuJob && } + {menuJob && statusOptions.map((status) => runRowAction(() => void setStatusQuick(menuJob.id, status))}>{t("jobTableSetStatus", { status: statusLabel(t, status) })})} ); diff --git a/job-tracker-ui/src/job-table-density.test.tsx b/job-tracker-ui/src/job-table-density.test.tsx index 155fecee..c1473f9f 100644 --- a/job-tracker-ui/src/job-table-density.test.tsx +++ b/job-tracker-ui/src/job-table-density.test.tsx @@ -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" })); }); diff --git a/job-tracker-ui/src/workflow-trust-signals.test.tsx b/job-tracker-ui/src/workflow-trust-signals.test.tsx index 6b95e004..70a304d9 100644 --- a/job-tracker-ui/src/workflow-trust-signals.test.tsx +++ b/job-tracker-ui/src/workflow-trust-signals.test.tsx @@ -198,7 +198,7 @@ test('package-work workflow signals route all overview surfaces to the shared ta } /> ); - 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 } /> ); - 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 } /> ); - 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')); });