feat(jobs): redesign table and mobile cards with preserved workflows

This commit is contained in:
cesnimda
2026-09-28 23:30:43 +02:00
parent 0f54007015
commit 7127005e48
20 changed files with 192 additions and 63 deletions
+3 -3
View File
@@ -10,16 +10,16 @@ Reconciled against [P4-06](../phase-4/p4-06/README.md#opendeferred-issue-reconci
| P4-03-D01 light Redo Tooltip 3.89:1 | Addressed in P5-00 shared opaque semantic pair; narrow old exception removed | Tooltip gate ready for tested loaded targets; not universal accessibility certification |
| P4-04-O01 toast covers bottom action | Addressed in P5-00: bottom feedback clearance, native success toast tested while open | Feedback gate ready for the defined Connections action; not a universal overlay manager or company-modal error repair |
| P5-00-D01 fixed worker data with system clock | **Closed 2026-09-21**: test now injects its existing fixed clock; complete backend rerun 916/916 | P5-01 baseline gate satisfied; production worker unchanged, no skip/expected annotation |
| P5-00-D02 browser Back loses Jobs row focus | **Open** existing route behavior exposed by waiting for the actual workspace before Back; all four locale/theme cases. Explicit workspace Back passes `focusJobId` and remains protected | **Blocks affected P5-03/08 navigation accessibility acceptance**, not unchanged shell. Browser query and local-density reset plus explicit-button focus have separate oracles; no route/table application fix in P5-00 |
| P5-00-D02 browser Back loses Jobs row focus | **Closed for Jobs in [P5-03](p5-03/README.md)**: originating list history entry retains only the numeric row focus target; all four locale/theme real-browser cases return after workspace load and focus the row. Explicit workspace Back remains protected | P5-03 Jobs navigation gate met. Revalidate the same contract if P5-08 changes workspace navigation or route behavior |
| P3-O02 command error→empty / late query results | **Addressed in P5-02**: failed search has Retry, aborted/late results are discarded after a newer query or close; focused and real-browser regressions pass | Search-state gate closed locally; manual screen-reader acceptance is separately open below |
| P5-02-D01 utility screen-reader operation | Automated axe and keyboard checks pass, but no human screen-reader command/notification task has been recorded | **Blocks P5-02 accessibility acceptance and automatic P5-03 progression** under the approved P5-02 plan. Operator steps and evidence request are in [P5-02](p5-02/README.md) |
| P5-02-D01 utility screen-reader operation | Automated axe and keyboard checks and command/notification semantic regressions pass; spoken names and live/state announcements have not been manually verified | **Open mandatory P5-15 accessibility acceptance item**. It does not block independent P5-03 Jobs work under Connor's explicit scope clarification. Operator steps and evidence request are in [P5-02](p5-02/README.md) |
| P3-O02 workspace search replacement loses `followMode` | Source-confirmed `setParams({section})` | **Blocks later slice P5-08b** adapter migration until retained-query proof |
| P4-02-D03 mobile progress scroll not focusable | Open loaded axe finding | **Blocks P5-08a affected Overview migration**, not shell; targeted G-WORKSPACE proof |
| P4-02-D04 literal AI context `{name}` | Open both locales | **Blocks P5-08b affected application AI labels**; correct interpolation without changing context/API |
| P4-02-D02 company-save error outside accessible modal | Open; draft retained, transient toast hidden by modal isolation | **Does not block Phase 5 shell/navigation**. Company form/Phase 7 owner; direct company-form restructuring requires durable in-modal error. P5-00 toast position does not close this |
| P4-04-O02 concurrent SQLite notification save | Open, earlier 2/10 native attempts HTTP 500 / database locked | **Separate backend reliability**; no shell/visual blocker with unchanged calls, but any increased polling/write concurrency waits for proof. Sequential passing test does not close it |
| P2-O01 isolated rules-save 500 | Historical unconfirmed, cause not established | **Separate reliability triage** if recurrent; keep successful native oracle, no generic retry mask |
| P4-05-O01 English status actions/browser-locale dates | Open display debt; status values must stay stable | **Blocks P5-03 localized table acceptance**; display-only standard status/date formatting, preserve custom text/date meaning |
| P4-05-O01 English status actions/browser-locale dates | **Closed for Jobs/Trash in [P5-03](p5-03/README.md)**: standard status display uses existing translations, custom status text and raw API keys remain stable, and applied/deadline display follows UI language | P5-03 localized table gate met in English/Bokmål; stored date/time semantics are unchanged |
| P2-D01 native SQLite tailoring/capture | Open expected browser failure; archived-binary assertions unreached | **Blocks P5-08b and package/tailoring relationship changes**, future Documents; separate provider-compatible repair plus native archive proof. Does not gate independent shell/board/dashboard |
| F01 / audit D01 stale CV revision (related profile/job revisions distinct) | Open version increments without expected revision guard | **Blocks P5-09–14 broad CV**; separately authorized integrity work with cross-client proof. Profile/job persistence changes likewise gated, but their unchanged page links are safe. No automatic B07 |
| Creation-intent idempotency | Open independent contract; not B02 identity or stale revision | **Separate backend/product work**; no Add/import/POST-on-mount expansion in Phase 5. Existing create review/no-write navigation must remain |
+1 -1
View File
@@ -37,7 +37,7 @@ Supply-chain, model-evaluation harness and browser-extension local checks passed
The final production-browser run and a focused repeat passed four utility journeys: light/English and dark/English at 1440px; light/Bokmål and dark/Bokmål at 390px. They exercised search outage, keyboard command activation, unread/read/dismiss and captured both utility overlays. Scoped axe scans of the command error and notification panel had zero violations; their incomplete nodes are not counted as passes. Eight final-build captures are under `visual/`. Direct review confirmed the light desktop error alert remains readable and the dark Bokmål phone notification panel fits below the app bar without covering its own actions. Manual screen-reader operation, zoom/forced-colour behavior and provider outage timing remain explicit validation gaps.
**Acceptance status: conditional.** The [approved plan](../PHASE5_PLAN.md#p5-02--global-shell-utilities) requires a manual screen-reader command and notification task, or explicit blocking of accessibility acceptance pending operator proof. Automated axe and keyboard tests do not substitute for that task. A Windows operator should use Narrator/Edge or NVDA/Chrome on the committed build: open search with Ctrl+K, hear the dialog/search name and settled count, move between grouped actions with arrow/Home/End and Tab, activate a job/company result, close with Escape and confirm focus return; open the bell, hear loading/error/unread state, then read, dismiss and open an operation link. Repeat representative English/Bokmål and light/dark states. Record the spoken names/order, any silent state transition, and screenshot or short recording with credentials hidden. Until this passes, P5-02 is committed but **P5-03 progression is blocked** under the sequential gate.
**Acceptance status: conditional.** The [approved plan](../PHASE5_PLAN.md#p5-02--global-shell-utilities) requires a manual screen-reader command and notification task, or explicit blocking of accessibility acceptance pending operator proof. Automated axe and keyboard tests do not substitute for that task. A Windows operator should use Narrator/Edge or NVDA/Chrome on the committed build: open search with Ctrl+K, hear the dialog/search name and settled count, move between grouped actions with arrow/Home/End and Tab, activate a job/company result, close with Escape and confirm focus return; open the bell, hear loading/error/unread state, then read, dismiss and open an operation link. Repeat representative English/Bokmål and light/dark states. Record the spoken names/order, any silent state transition, and screenshot or short recording with credentials hidden. Connor subsequently clarified that this remains a mandatory P5-15 accessibility acceptance item but does not block independent P5-03 Jobs work; no manual screen-reader pass is claimed here.
## Remaining boundaries
+34
View File
@@ -0,0 +1,34 @@
# P5-03 — Jobs Table and mobile cards
Source: [approved Phase 5 plan](../PHASE5_PLAN.md#p5-03--jobs-table-and-mobile-cards). Checkpoint starts at `0f54007` and changes the shared `/jobs` and `/trash` renderer only. It does not start Board presentation or Table/Board integration.
## Scope and preservation
The existing `JobTable` remains the list, filter, sort, selection, bulk-action, paging, density and Trash owner. `SavedViewsMenu` retains account-scoped browser storage. The redesign gives the filter toolbar, selection bar, table and phone cards restrained semantic surfaces, borders and typography; desktop remains a table, phones remain cards. P4-05 Comfortable/Compact spacing remains local to the mounted view. All optional columns, status labels, row actions, listing links, workflow chips, 15/20/25 page sizes, native API mutations and route/query contracts remain available. The Add/import, Board and dedicated job workspace implementations are unchanged.
| Contracts | Retained behavior → P5-03 change | Automated evidence | Visual evidence |
|---|---|---|---|
| PRES-J05/J06, PRES-S03 | Filters/saved views, owner scope, sort/page/columns/density, selection and partial bulk retry, edit/status/delete/Trash restore → clearer existing controls without new data contract | `job-table-density.test.tsx`, `e2e/table-density.spec.ts`, backend Jobs authorization/controller tests | Four locale/theme Jobs captures in both densities and desktop/phone; selected, error and Trash states |
| PRES-W01/W02 | Job opening, workflow signals and explicit workspace Back → browser Back now restores the originating list row after the workspace unmounts | Real-App browser Back and explicit Back assertions; job workspace tests | Focused row/scroll captures |
| PRES-E07/E08/E09 | Shell navigation, EN/Bokmål, light/dark and owner-private saved views → existing shell remains; table labels and dates use UI language | Saved-view account switch, locale/status API-value regression, shell and session suites | Four theme/language combinations |
| PRES-J03/J04/J08–J10 | Existing capture and nearby navigation entry points → no workflow changes | Existing quick-capture, account, import and link tests | Shell destination visibility in Jobs captures |
Browser Back focus previously depended on the explicit workspace Back button passing `focusJobId`. Opening a row now stores only its numeric focus target on the *current list history entry* before navigation; on return the existing row-focus effect consumes that target after data loads. The latest opened row wins when a user re-enters a different job. The query, scroll target and ordinary browser history remain intact. This is navigation metadata only, never owner identity or job data.
P4-05-O01 is addressed at the display boundary: standard status names in filter, bulk/quick actions, toast and saved-view summaries use existing `pipeline.statusLabel`; API values remain raw keys and custom labels remain raw. Applied/deadline dates use the current EN/Bokmål UI locale; no stored timestamp or timezone conversion contract changed. Custom status deep links retain their raw filter value and an explicit dropdown option. Result-changing filter/sort/page transitions clear selection before old IDs can be bulk-mutated. A partial bulk outcome refreshes successful mutations and keeps only failed, still-visible IDs selected for retry.
## Validation and visual review
The inherited backend count **916→918** is accounted for by the two tests in commit `57a6bd0` (`Concurrent_rule_reads_share_the_single_created_settings_row` and `Concurrent_checklist_reads_share_the_single_default_item_set`), added before P5-01. The inherited P5-02 baseline is backend **918**, frontend **373**, browser **74 actual passes + unchanged P2-D01 expected failure**, rate-limit **9**, ingress **6**, PDF **1**, configuration **3**.
Final post-change results: backend **918/918**, frontend **377/377** (**+4** focused Jobs tests), and production-static Playwright **74 actual passes + unchanged P2-D01 expected failure** (75 reported passed, zero unexpected failures). Rate-limit **9/9**, ingress **6/6**, PDF admission **1/1**, configuration **3/3**. TypeScript (app and E2E), ESLint, frontend production build and API Release build passed. Supply-chain **3/3**, model eval **5/5**, browser-extension **3/3**, tracked secret scan and `git diff --check` passed. The first rate-limit invocation could not find the local .NET SDK; rerun with `DOTNET_ROOT=C:/Users/Connor/.dotnet` and `DOTNET_HOST_PATH=C:/Users/Connor/.dotnet/dotnet.exe` passed 9/9. No application code changed after the successful full rerun.
The browser fixture exercises the actual `/jobs` and `/trash` routes with 18 native owner-created jobs, a long Norwegian title, a custom status, all optional columns, a page boundary, both densities, status mutation, listing-link isolation, keyboard row opening, browser and explicit Back, soft delete/restore, loading, 503/Retry, 1440/1024/768 table and 390/320 card widths. It runs light/dark × English/Bokmål and checks 32px desktop/44px phone targets, overflow, semantic sort, visible scroll focus and scoped axe. Screenshots and manual review: pending final production-build run.
Manual review of the final rendered production build found the populated table and phone cards readable in light/dark and EN/Bokmål, with the original data density intact. The selected phone action bar now groups the five unchanged status mutations in one labelled menu instead of pushing the first job card far below the fold. The 320px Compact capture retains labelled actions and wraps long titles; desktop Compact retains all columns. The error capture keeps Retry visible. Keyboard focus on the quick-status action is visibly outlined; Browser Back restores the originating row after data reload. The browser fixture checks no page-level overflow at 1440/1024/768/390/320px and scoped axe in the tested states. The horizontally scrollable desktop table can show its far-edge actions only after horizontal scroll; this existing density/column trade-off was retained rather than dropping data. The occasional malformed Norwegian glyph in headless captures is the pre-existing screenshot-font limitation, not a changed stored value or DOM text.
Visual evidence: [light English desktop](visual/jobs-desktop-light-en.png), [light English phone](visual/jobs-phone-light-en.png), [light Bokmål desktop](visual/jobs-desktop-light-nb.png), [light Bokmål phone](visual/jobs-phone-light-nb.png), [dark English desktop](visual/jobs-desktop-dark-en.png), [dark English phone](visual/jobs-phone-dark-en.png), [dark Bokmål desktop](visual/jobs-desktop-dark-nb.png), [dark Bokmål phone](visual/jobs-phone-dark-nb.png), [desktop Compact](visual/desktop-compact-light-en.png), [320px Compact](visual/phone-320-compact-light-en.png), [error/retry](visual/list-error-light-en.png), [focused row action](visual/row-action-focus-light-en.png).
P5-00-D02 Browser Back focus and P4-05-O01 table display-localisation debt are closed for the tested Jobs/Trash boundary; Table/Board parity remains for P5-04b. Automated axe results are not manual screen-reader proof. P5-02-D01 remains an explicitly open command/notification spoken-name and live-announcement acceptance item for P5-15. P2-D01 remains an expected native-package browser failure, and F04 remains open pending production ingress evidence. SQLite contention, CV rendering fidelity and other separately owned debt are unchanged.
Rollback boundary: revert `JobTable` presentation/display/focus/selection changes and `SavedViewsMenu` status display together. There is no schema, API, provider or route migration.
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+5 -4
View File
@@ -1,20 +1,21 @@
# TODO
Status reconciled: 2026-09-21.
Status reconciled: 2026-09-28.
## Current repository work
- **P5-03 — Jobs Table and mobile cards complete:** restrained table/card presentation, existing Comfortable/Compact controls, localized display, partial-bulk retry, and Browser Back row focus have [real-browser and unit evidence](../redesign/phase-5/p5-03/README.md). Baseline: 918 backend, 377 frontend, 74 actual browser passes plus unchanged P2-D01 expected failure. P5-00-D02 and P4-05-O01 close for Jobs/Trash; P5-02 manual screen-reader acceptance and F04 remain open. Stop before P5-04a.
- **P5-01 — AppShell/grouped navigation complete:** existing destinations are grouped under Dashboard, Job search, Career, Admin and Account; expanded/rail/mobile access, parent/child current semantics, skip/main and route-heading focus have automated and visual evidence. Final baseline: 918 backend, 365 frontend, 70 browser passes plus unchanged P2-D01 expected failure. [Record](../redesign/phase-5/p5-01/README.md). P5-02 remains separate; no route/provider/business change or B07 work.
- **P5-02 — global shell utilities implemented, accessibility acceptance conditional:** command error/retry and stale-result isolation, grouped existing commands, keyboard selection, and notification stale-load/read/dismiss presentation pass 918 backend, 373 frontend and 74 actual browser tests plus unchanged P2-D01 expected failure. [Record](../redesign/phase-5/p5-02/README.md). P5-02-D01 requires a manual screen-reader utility task before P5-03 progression; F04 remains open.
- **P5-02 — global shell utilities implemented, accessibility acceptance conditional:** command error/retry and stale-result isolation, grouped existing commands, keyboard selection, and notification stale-load/read/dismiss presentation pass 918 backend, 373 frontend and 74 actual browser tests plus unchanged P2-D01 expected failure. [Record](../redesign/phase-5/p5-02/README.md). P5-02-D01's spoken-name and live/state-announcement check remains mandatory for P5-15; it does not block independent P5-03 Jobs work. F04 remains open.
- **P5-00 — complete, P5-01 GO:** [record](../redesign/phase-5/p5-00/README.md) covers the five bounded shell gates, shared 1024px navigation boundary and open-drawer resize lifecycle. P5-00-D01 is closed with the existing fixed clock injected into the test; backend rerun is 916/916. No grouped-navigation redesign or B07 work in that checkpoint. F04 remains open.
- **P5-00-D02 — open Jobs browser-Back focus debt:** after the workspace actually renders, browser Back preserves the query but does not restore row focus. The explicit workspace Back button carries the existing focus contract. Corrected browser evidence distinguishes them; no application behavior changed. Resolve before affected P5-03/08 navigation migration. [Evidence](../redesign/phase-5/p5-00/README.md#inherited-density-navigation-oracle-corrected).
- **P5-00-D02 — closed for Jobs:** P5-03 stores the originating row focus target on the list history entry and restores it after Browser Back and data load; explicit workspace Back remains intact. Four locale/theme browser cases pass. Revalidate if P5-08 changes workspace navigation. [Evidence](../redesign/phase-5/p5-03/README.md).
- **Phase 5 plan:** [bounded plan](../redesign/phase-5/README.md) maps all 57 contracts/31 routes. Board integration, native package and CV revision/rendering gates apply only to dependent slices. F04 remains open.
- **P2-D01 — open:** repair the SQLite `DateTimeOffset` ordering baseline before package/tailoring restructuring; retain the expected-failure browser oracle until native capture and historical-binary preservation are proven. P2-O01's initial rules-500 behavior also remains unconfirmed.
- **P3-O01/P3-O02 — open:** address analytics population/period labels and navigation/query/error retention when those surfaces are next changed. Evidence is in the current [redesign decision records](../redesign/phase-3/OPEN_DECISIONS.md).
- **P4-02-D02–D04 — open accessibility/content debt:** company save-error feedback outside the modal's accessible subtree, mobile application-progress scroll region and literal AI context `{name}`. D01 command-list semantics are addressed in P5-00; D02–D04 are unchanged. [Original evidence](../redesign/phase-4/p4-02/README.md). Do not disable modal background isolation or hide findings.
- **P5-00 scoped closures:** P4-03-D01 Tooltip contrast, P4-04-O01 bottom-action toast overlap, P4-06-O01 mobile labels/focus and P4-06-O02 synthetic long admin metadata are addressed with [readiness evidence](../redesign/phase-5/p5-00/README.md). This does not certify all overlays, all accessibility targets or deployed production behavior.
- **P4-04-O02 — existing SQLite notification-save contention:** overlapping native preference read/write requests reproduced HTTP 500 with SQLite error 5 in two of ten local runs. The sequential UI test now awaits and requires the save response before readback; server concurrency remains unresolved. Investigate separately from page-layout/density work. [Evidence](../redesign/phase-4/p4-04/README.md).
- **P4-05-O01 — existing table localization debt:** status menu/bulk actions expose raw English status keys in Bokmål, and applied dates follow the browser locale. Keep status values/date semantics intact until the Phase 5 Jobs localization work; P4-05 localizes its new density/paging/accessible names only. [Evidence](../redesign/phase-4/p4-05/README.md).
- **P4-05-O01 — closed for Jobs/Trash:** P5-03 localizes standard status display and date labels while retaining raw API status values, custom labels and stored dates. [Evidence](../redesign/phase-5/p5-03/README.md).
- **F04/B06 — open:** complete the authorized main release and production ingress/runtime verification; the browser gate and runner diagnostics remain unresolved. No gate should be bypassed.
## Delivery and feature gates
+16 -5
View File
@@ -89,6 +89,7 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
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}`);
const rowText = await first.innerText();
const headers = await main.getByRole("columnheader").allTextContents();
const select = first.getByRole("checkbox");
@@ -131,6 +132,13 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await expect(first.getByText("Avventer faglig vurdering", { exact: true })).toBeVisible();
if (width >= 768) { await expect(main.getByRole("table")).toBeVisible(); continue; }
await expect(main.getByRole("table")).toHaveCount(0);
if (width === 390 && value === "comfortable") {
await page.evaluate(() => window.scrollTo(0, 0));
await capture(page, info, `jobs-top-phone-${suffix}`);
await main.getByRole("button", { name: t.jobTableStatus, exact: true }).click();
await expect(page.getByRole("menuitem", { name: t.statusOffer, exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
// Geometric containment alone misses a title squeezed into one-letter lines.
expect((await first.getByText(/^Density 00/).boundingBox())!.width).toBeGreaterThanOrEqual(150);
const metrics = await first.evaluate(card => Array.from(card.querySelectorAll("button,a,.MuiCheckbox-root")).map(node => {
@@ -157,7 +165,7 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await capture(page, info, `row-action-focus-${suffix}`);
await statusAction.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}", "Interview"), exact: true }).click();
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview), exact: true }).click();
expect((await changed).status()).toBe(204);
await expect(first.getByText(t.statusInterview, { exact: true })).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/jobs");
@@ -170,17 +178,20 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[0]}$`));
await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible();
await page.goBack();
// The dedicated route unmounts the table. Browser Back preserves the query;
// density is deliberately local and returns to its default.
// Browser Back restores the originating row and query after the workspace unmounts.
expect(new URL(page.url()).search).toBe(query);
await expect(density).toHaveText(t.densityComfortable);
// P5-00-D02: browser Back does not restore row focus in the existing app.
// The explicit workspace Back action carries focusJobId; protect that path.
await expect(first).toBeFocused();
// The explicit workspace Back action retains the same focus contract.
await first.focus(); await first.press("Enter");
await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible();
await page.getByRole("button", { name: t.workspaceBack, exact: true }).click();
await expect(page).toHaveURL(url => url.pathname === "/jobs" && url.search === query);
await expect(first).toBeFocused();
await ordinary.focus(); await ordinary.press("Enter");
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[1]}$`));
await page.goBack();
await expect(ordinary).toBeFocused();
await main.getByRole("button", { name: t.tableNextPage }).click();
await expect(main.locator("[data-job-row-id]")).toHaveCount(3);
+61 -46
View File
@@ -28,7 +28,7 @@ import {
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles";
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";
@@ -111,7 +111,7 @@ function queryCompany(search: string): number | "All" {
function queryStatus(search: string): string {
const value = new URLSearchParams(search).get("status");
return value && JOB_STATUS_FILTERS.includes(value) ? value : "All";
return value || "All";
}
function queryReadiness(search: string): ReadinessFilter {
@@ -168,15 +168,15 @@ function isInteractiveTarget(target: EventTarget | null): boolean {
}
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
const theme = useTheme();
const isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
const navigate = useNavigate();
const listRouteRef = useRef(`${location.pathname}${location.search}`);
const restoredFocusJobIdRef = useRef<number | null>(null);
const retainedSelectionAfterReloadRef = useRef<number[] | null>(null);
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => queryPage(location.search));
@@ -197,11 +197,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [editJobId, setEditJobId] = useState<number | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
const [bulkStatusAnchor, setBulkStatusAnchor] = useState<null | HTMLElement>(null);
const [statusJobId, setStatusJobId] = useState<number | null>(null);
const [sortBy, setSortBy] = useState<JobSortKey>(() => querySort(location.search));
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => new URLSearchParams(location.search).get("sortDir") === "asc" ? "asc" : "desc");
const updateListRoute = useCallback((updates: Record<string, string | null>) => {
setSelectedIds([]);
const next = new URLSearchParams(location.search);
Object.entries(updates).forEach(([key, value]) => {
if (value) next.set(key, value);
@@ -293,6 +295,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
};
const openJob = useCallback((jobId: number, path?: string) => {
// Keep the originating row on the list history entry so browser Back can restore focus.
window.history.replaceState({ ...window.history.state, jobListFocusId: jobId }, "");
navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current, focusJobId: jobId } });
}, [navigate]);
@@ -327,7 +331,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
setJobs(jobsResource.data.items);
setTotal(jobsResource.data.total);
if (!jobsResource.error) {
setSelectedIds([]);
setSelectedIds(retainedSelectionAfterReloadRef.current?.filter((id) => jobsResource.data.items.some((job) => job.id === id)) ?? []);
retainedSelectionAfterReloadRef.current = null;
}
}, [jobsResource.data, jobsResource.error]);
@@ -342,7 +347,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const filteredJobs = jobs;
useEffect(() => {
const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId;
const focusJobId = window.history.state?.jobListFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
const row = document.querySelector<HTMLElement>(`[data-job-row-id="${focusJobId}"]`);
if (!row) return;
@@ -401,7 +406,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const setStatusQuick = async (id: number, status: string) => {
try {
await api.patch(`/jobapplications/${id}/status`, { status });
toast(t("jobTableStatusSet", { status }), "success");
toast(t("jobTableStatusSet", { status: statusLabel(t, status) }), "success");
setReloadToken((token) => token + 1);
} catch {
toast(t("jobTableStatusUpdateFailed"), "error");
@@ -412,17 +417,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
if (selectedIds.length === 0) return;
const selectedJobs = jobs.filter((job) => selectedIdSet.has(job.id));
if (action === "delete" && !(await confirmDelete(selectedJobs))) return;
try {
await Promise.all(selectedIds.map((id) => {
const results = await Promise.allSettled(selectedIds.map((id) => {
if (action === "delete") return api.delete(`/jobapplications/${id}`);
if (action === "restore") return api.post(`/jobapplications/${id}/restore`);
return api.patch(`/jobapplications/${id}/status`, { status: value });
}));
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
const failedIds = selectedIds.filter((_, index) => results[index].status === "rejected");
if (failedIds.length) toast(t("jobTableBulkActionFailed"), "error");
else toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
if (failedIds.length !== selectedIds.length) {
retainedSelectionAfterReloadRef.current = failedIds;
setReloadToken((token) => token + 1);
setSelectedIds([]);
} catch {
toast(t("jobTableBulkActionFailed"), "error");
}
};
@@ -451,10 +456,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
toneName: statusTone(job.status),
tags: parseTags(job.tags),
actionSignals: actionSignal ? [actionSignal] : [],
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—",
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—",
isSelected: selectedIdSet.has(job.id),
};
}), [buildWorkflowActionSignal, filteredJobs, selectedIdSet]);
}), [buildWorkflowActionSignal, filteredJobs, selectedIdSet, language]);
const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const visibleDesktopColumns = 6 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl);
@@ -472,7 +477,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return (
<Box>
{isMobile ? (
<Paper sx={{ mt: 2, p: 1.25, borderRadius: 4 }}>
<Paper variant="outlined" sx={{ mt: 2, p: 1.25, borderRadius: 2.5, boxShadow: "none" }}>
<Stack spacing={1.1}>
<TextField
label={t("jobTableSearch")}
@@ -488,7 +493,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<FormControl fullWidth size="small">
<InputLabel id="job-table-status-label">{t("jobTableStatus")}</InputLabel>
<Select labelId="job-table-status-label" value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{JOB_STATUS_FILTERS.map((status) => <MenuItem key={status} value={status}>{statusLabel(t, status)}</MenuItem>)}
{statusFilter !== "All" && !JOB_STATUS_FILTERS.includes(statusFilter) ? <MenuItem value={statusFilter}>{statusFilter}</MenuItem> : null}
</Select>
</FormControl>
@@ -530,10 +537,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
gap: 0.25,
px: 0.25,
py: 0.5,
borderRadius: 3,
backgroundColor: alpha(theme.palette.primary.main, 0.03),
borderRadius: 2,
backgroundColor: "action.hover",
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.1),
borderColor: "divider",
}}
>
<FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0, ml: -0.5 }} />
@@ -553,7 +560,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Stack>
</Paper>
) : (
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
<Paper variant="outlined" sx={{ display: "flex", gap: 1.5, alignItems: "center", mt: 2, p: 1.5, flexWrap: "wrap", borderRadius: 2.5, boxShadow: "none" }}>
<TextField
label={t("jobTableSearch")}
value={search}
@@ -567,7 +574,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<FormControl sx={{ width: { xs: "100%", sm: 180 } }} size="small">
<InputLabel id="job-table-status-label">{t("jobTableStatus")}</InputLabel>
<Select labelId="job-table-status-label" value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{JOB_STATUS_FILTERS.map((status) => <MenuItem key={status} value={status}>{statusLabel(t, status)}</MenuItem>)}
{statusFilter !== "All" && !JOB_STATUS_FILTERS.includes(statusFilter) ? <MenuItem value={statusFilter}>{statusFilter}</MenuItem> : null}
</Select>
</FormControl>
@@ -604,18 +613,22 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
{densityControl}
</Box>
</Box>
</Paper>
)}
{selectedIds.length > 0 ? (
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" } }}>
<Paper variant="outlined" sx={{ mt: 1.5, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", borderColor: "primary.main", borderRadius: 2.5, boxShadow: "none" }}>
<Typography sx={{ fontWeight: 700 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" }, "& .MuiButton-root": { minHeight: isMobile ? 44 : undefined } }}>
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")} sx={{ width: { xs: "100%", sm: "auto" } }}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")} sx={{ width: { xs: "100%", sm: "auto" } }}>{t("jobTableDeleteSelected")}</Button>}
{mode === "jobs" ? statusOptions.map((status) => <Button key={status} variant="outlined" onClick={() => void runBulkAction("status", status)} sx={{ width: { xs: "100%", sm: "auto" } }}>{status}</Button>) : null}
{mode === "jobs" && isMobile ? <Button variant="outlined" onClick={(event) => setBulkStatusAnchor(event.currentTarget)} sx={{ width: "100%" }}>{t("jobTableStatus")}</Button> : null}
{mode === "jobs" && !isMobile ? statusOptions.map((status) => <Button key={status} variant="outlined" onClick={() => void runBulkAction("status", status)}>{statusLabel(t, status)}</Button>) : null}
</Box>
</Paper>
) : null}
<Menu anchorEl={bulkStatusAnchor} open={Boolean(bulkStatusAnchor)} onClose={() => setBulkStatusAnchor(null)} MenuListProps={{ "aria-label": t("jobTableStatus") }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { setBulkStatusAnchor(null); void runBulkAction("status", status); }}>{statusLabel(t, status)}</MenuItem>)}
</Menu>
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
{([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => (
@@ -645,11 +658,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Paper
data-content-density={density}
sx={{
mt: 2,
mt: 1.5,
overflow: "hidden",
borderRadius: 4,
border: "none",
boxShadow: "var(--customShadows-section, none)",
borderRadius: 2.5,
border: "1px solid",
borderColor: "divider",
boxShadow: "none",
}}
>
{isMobile ? (
@@ -677,13 +691,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
openJob(job.id);
}
}}
variant="outlined"
sx={{
p: "var(--app-list-item-padding)",
borderRadius: 3.5,
borderRadius: 2,
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
backgroundColor: isSelected ? "action.selected" : alpha(theme.palette.primary.main, 0.03),
borderColor: alpha(theme.palette.primary.main, 0.08),
boxShadow: `0 10px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.18 : 0.06)}`,
backgroundColor: isSelected ? "action.selected" : "background.paper",
borderColor: "divider",
boxShadow: "none",
"&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined,
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 },
}}
@@ -696,12 +711,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
{job.company?.name ?? t("jobTableCompany")}
</Typography>
<Typography sx={{ fontWeight: 900, fontSize: 21, lineHeight: 1.1, letterSpacing: -0.4, textWrap: "balance", overflowWrap: "anywhere" }}>
<Typography sx={{ fontWeight: 700, fontSize: 16, lineHeight: 1.35, overflowWrap: "anywhere" }}>
{job.jobTitle}
</Typography>
</Box>
</Box>
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
{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>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
@@ -756,7 +771,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1, "& .MuiButton-root": { minHeight: 44, whiteSpace: "normal", lineHeight: 1.25 } }}>
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={() => setEditJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableEdit")}
</Button>
@@ -801,7 +816,6 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TableBody>
{jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null}
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, appliedDateLabel, isSelected }) => {
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
return (
<TableRow
key={job.id}
@@ -821,15 +835,16 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
}}
sx={{
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06),
backgroundColor: isSelected ? "action.selected" : "background.paper",
"&:hover": { backgroundColor: "action.hover" },
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: -3 },
}}
>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
<TableCell sx={{ minWidth: 140, fontWeight: 700 }}>{job.company?.name ?? ""}</TableCell>
<TableCell sx={{ minWidth: 140, color: "text.secondary", fontWeight: 600 }}>{job.company?.name ?? ""}</TableCell>
<TableCell sx={{ minWidth: 260 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography component="span" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
<Typography component="span" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
{actionSignals.map((signal) => (
<Chip
key={`${job.id}-${signal.label}-${signal.detail}`}
@@ -838,7 +853,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
color={signal.color}
variant={signal.variant === "contained" ? "filled" : "outlined"}
title={signal.detail}
sx={{ fontWeight: 800, cursor: "pointer" }}
sx={{ fontWeight: 700, cursor: "pointer" }}
clickable
onClick={signal.onClick}
aria-label={t("jobTableWorkflowSignal", { title: job.jobTitle, signal: signal.label })}
@@ -847,10 +862,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
</TableCell>
<TableCell sx={{ minWidth: 130 }}>{job.location || "—"}</TableCell>
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 600 }} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince ?? "—"}</TableCell> : null}
<TableCell>{job.deadline ? new Date(job.deadline).toLocaleDateString() : "—"}</TableCell>
<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" }}>
@@ -874,7 +889,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<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 })}</MenuItem>)}
{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>
</Box>
);
@@ -16,6 +16,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useI18n } from "../i18n/I18nProvider";
import { AUTH_USER_CHANGED_EVENT, getUserScopedStorageKey } from "../auth";
import { statusLabel } from "../pipeline";
export type SavedViewParams = {
q?: string;
@@ -154,7 +155,7 @@ export default function SavedViewsMenu({
primary={v.name}
secondary={
[
v.params.status ? t("savedViewsStatusLabel", { value: v.params.status }) : null,
v.params.status ? t("savedViewsStatusLabel", { value: statusLabel(t, v.params.status) }) : null,
v.params.location ? t("savedViewsLocationLabel", { value: v.params.location }) : null,
v.params.needsFollowUp ? t("savedViewsNeedsFollowUp") : null,
]
+69 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { api } from "./api";
import JobTable, { type JobTableColumns } from "./components/JobTable";
@@ -9,6 +9,7 @@ import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { getUserScopedStorageKey, setAuthUserKey } from "./auth";
jest.mock("./components/EditJobDialog", () => ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? <div role="dialog" aria-label="Edit job"><button onClick={onClose}>Close editor</button></div> : null);
@@ -35,7 +36,7 @@ beforeEach(() => {
test("row status/edit/delete controls retain selection and do not open the workspace", async () => {
show();
const row = await screen.findByRole("row", { name: /open platform engineer/i });
const row = await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(within(row).getByRole("checkbox"));
fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableEdit}: ${job.jobTitle}` }));
expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible();
@@ -60,6 +61,9 @@ test("table sort, page-size and optional columns keep their existing independent
fireEvent.mouseDown(screen.getByRole("combobox", { name: /rows per page/i }));
fireEvent.click(await screen.findByRole("option", { name: "20" }));
await waitFor(() => expect(lastParams()).toMatchObject({ page: 1, pageSize: 20 }));
fireEvent.mouseDown(screen.getByRole("combobox", { name: /rows per page/i }));
fireEvent.click(await screen.findByRole("option", { name: "25" }));
await waitFor(() => expect(lastParams()).toMatchObject({ page: 1, pageSize: 25 }));
fireEvent.click(screen.getByRole("button", { name: t.jobTableColumns }));
fireEvent.click(await screen.findByRole("menuitem", { name: t.settingsColumnJobUrl }));
expect(screen.queryByRole("link", { name: t.jobTableLink })).not.toBeInTheDocument();
@@ -125,3 +129,66 @@ test("density survives an empty result and a failed refresh with the existing re
expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityCompact);
expect(screen.queryByText(job.jobTitle)).not.toBeInTheDocument();
});
test("Bokmål display localizes standard statuses without changing API values or custom status filters", async () => {
localStorage.setItem("uiLanguage", "nb");
const standardJob = { ...job, status: "Interview", dateApplied: "2026-08-01", deadline: "2026-08-10" };
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [standardJob], total: 1, page: 1, pageSize: 15 } : [] } as any));
show("jobs", "?status=Custom%20review");
const row = await screen.findByRole("row", { name: /platform engineer/i });
expect(lastParams().status).toBe("Custom review");
expect(within(row).getByText(translations.nb.statusInterview)).toBeVisible();
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(await screen.findByRole("menuitem", { name: translations.nb.jobTableSetStatus.replace("{status}", translations.nb.statusOffer) }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" }));
});
test("partial bulk failure refreshes successful rows and retains only failed visible IDs", async () => {
const other = { ...job, id: 43, jobTitle: "Second role" };
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job, other], total: 2, page: 1, pageSize: 15 } : [] } as any));
mockedApi.patch.mockImplementation((url: string) => url.includes("/43/") ? Promise.reject(new Error("Synthetic failure")) : Promise.resolve({ status: 204 } as any));
show();
const first = await screen.findByRole("row", { name: /open platform engineer/i });
const second = screen.getByRole("row", { name: /open second role/i });
fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll }));
fireEvent.click(screen.getByRole("button", { name: "Offer" }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2));
await waitFor(() => expect(within(first).getByRole("checkbox")).not.toBeChecked());
expect(within(second).getByRole("checkbox")).toBeChecked();
expect(calls().length).toBeGreaterThan(1);
});
test("saved view create/apply/delete stays scoped to the signed-in account", async () => {
setAuthUserKey("account-a");
show("jobs", "?q=platform&status=Interview");
await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip }));
fireEvent.change(screen.getByRole("textbox", { name: t.savedViewsName }), { target: { value: "Interview search" } });
fireEvent.click(screen.getByRole("button", { name: t.savedViewsSaveCurrent }));
expect(localStorage.getItem(getUserScopedStorageKey("jt_saved_views_v1"))).toContain("Interview search");
fireEvent.click(screen.getByRole("menuitem", { name: /Interview search/ }));
expect(screen.getByLabelText("Current route")).toHaveTextContent("status=Interview");
act(() => setAuthUserKey("account-b"));
fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip }));
expect(screen.getByText(t.savedViewsEmpty)).toBeVisible();
act(() => setAuthUserKey("account-a"));
await waitFor(() => expect(screen.queryByText(t.savedViewsEmpty)).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip }));
expect(screen.getByRole("menuitem", { name: /Interview search/ })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: `${t.attachmentsDelete}: Interview search` }));
expect(localStorage.getItem(getUserScopedStorageKey("jt_saved_views_v1"))).not.toContain("Interview search");
});
test("changing the result set clears selection before an old row can be bulk-updated", async () => {
show();
const row = await screen.findByRole("row", { name: /platform engineer/i });
fireEvent.click(within(row).getByRole("checkbox"));
expect(screen.getByText(t.jobTableSelected.replace("{count}", "1"))).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: t.jobTableSearch }), { target: { value: "different" } });
expect(within(row).getByRole("checkbox")).not.toBeChecked();
expect(screen.queryByRole("button", { name: "Offer" })).not.toBeInTheDocument();
expect(mockedApi.patch).not.toHaveBeenCalled();
});