style(ui): open application rows and workspace sections

65 targeted tests and focused six-state phone pilot pass. Full 216-state visual qualification remains pending; final owner acceptance is separate.
This commit is contained in:
cesnimda
2026-11-04 00:56:57 +01:00
parent af2df36062
commit c9138af01e
10 changed files with 269 additions and 58 deletions
@@ -0,0 +1,20 @@
# Application list and workspace visual correction — active
4 November 2026. Approved IM10 continuation from published/remotely verified `af2df360624d5c48ae03d80e7281704372d860ee` (secondary source checkpoint `69621788ee53d842c82cab307be129ba8806ca26`). No owner permission is outstanding for ordinary visual correction.
Material before evidence: `evidence/applications` has36 configurations/144 successful controlled accessibility states but still shows a bordered collection enclosing large mobile job cards and a large boxed detail header. These are functional/accessibility results, not final A3 visual acceptance. Approved A3 applications390 and detail1440 reference images were inspected without restarting design selection.
Uncommitted correction:
- Mobile rows use quiet separators and full available width, inline existing facts and direct44px named icon actions. Every edit/status/delete/restore handler and confirmation remains. Column/density preferences, selection, tags/signals, pagination, role/tabIndex, interactive-target guards, keyboard navigation and return state remain.
- Workspace header, continuous document/notes/calendar/correspondence/next-action/recent-activity sections and expanded recorded details/advert use open sections. Two-column phone preparation links retain all five destinations. Header/Accordion padding reduced; right-side context grid and jump/return targets unchanged.
-65 tests/six suites passed, including EN/NB mobile actions independent of row navigation and existing cover-letter preservation. Types/lint/build passed. A native test initially queried background controls before the confirmation dialog finished exiting; corrected to await accessible restoration. Its failed report is retained.
- Independent source review found no actionable findings; native touch/icon discoverability and focus visibility still require rendered inspection.
Browser evidence is deliberately distinct: `applications-flat` is the successor directory. The first36-configuration run covers five states per configuration (180 intended captures) but does **not** exercise a focused table row. A new focused-row pilot reproduced a real clipped outline in the borderless mobile collection; see `FOCUS-CLIP-FAILURE.json`. The responsible collection overflow was changed to visible on phones, retaining desktop containment. The new verifier adds a focused-row state and an explicit outline/clipping assertion: final required successor is **36 configurations /216 states**, not180. Do not attribute the older180-state run to the focus fix or mark this package complete until the final export and216-state run pass.
Successor update: the older non-focused run completed180 states but cannot close the focus gap; retained as `PREDECESSOR-180.json` without claiming an immutable source manifest or using it for final qualification. Focus correction export built successfully; the new six-state phone pilot passed, including an explicit visible/unclipped row outline assertion. Final36-config/216-state run is next. Types/build and full lint passed for this correction. Do not add pilot/predecessor results to final totals.
Remaining work: run full216, inspect phone/tablet/desktop and EN/NB/dark expanded detail captures, review source/evidence identities, publish/read back validated source and evidence checkpoints. Backend/physical PDF/provider/model/native/final security/release qualifications remain separate. Only app chrome changed; do not reopen accepted physical PDF metrics without an affected renderer change.
Next eligible packages after this bounded correction: account continuous sections (V10), remaining route visual review/connected workflows, and the bounded literal-date localisation defect (V11). Whole34-route/57-group acceptance and final owner review remain OPEN. No orchestrator, main merge, production or deferred import-engine work.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
{
"screens": [
{
"width": 320,
"language": "en",
"mode": "light",
"state": "table",
"violations": []
}
],
"journeys": [],
"aliases": [],
"focusChecks": [
{
"width": 320,
"language": "en",
"mode": "light",
"outlineStyle": "solid",
"outlineWidth": "3px",
"clippedBy": [
"DIV"
]
}
],
"errors": [],
"unexpectedWrites": [],
"mockedWrites": [],
"unavailableReads": [],
"blockedExternal": [
"https://fonts.googleapis.com"
],
"limits": [
"controlled fixtures, not real backend integration",
"secondary service reads may return honest unavailable states",
"no native device, provider, model, security or release qualification"
]
}
@@ -0,0 +1,103 @@
{
"screens": [
{
"width": 320,
"language": "en",
"mode": "light",
"state": "table",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "table-focused",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "add-job",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "board",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "detail",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "detail-expanded",
"violations": []
}
],
"journeys": [
{
"width": 320,
"language": "en",
"mode": "light",
"searchBoardPreciseStatusDetailContextReturn": "PASS"
}
],
"aliases": [
{
"legacy": "/kanban?q=nurse#retained",
"destination": "/jobs",
"queryPreserved": "PASS"
},
{
"legacy": "/applications/42?section=overview",
"destination": "/jobs/42",
"queryPreserved": "PASS"
},
{
"legacy": "/correspondence/review?q=recorded#retained",
"destination": "/correspondence",
"queryPreserved": "PASS"
}
],
"focusChecks": [
{
"width": 320,
"language": "en",
"mode": "light",
"outlineStyle": "solid",
"outlineWidth": "3px",
"clippedBy": []
}
],
"errors": [],
"unexpectedWrites": [],
"mockedWrites": [
{
"width": 320,
"language": "en",
"mode": "light",
"status": "Waiting"
}
],
"unavailableReads": [],
"blockedExternal": [
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com"
],
"limits": [
"controlled fixtures, not real backend integration",
"secondary service reads may return honest unavailable states",
"no native device, provider, model, security or release qualification"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -5,11 +5,13 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX); const visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX);
const flatWorkspace = Boolean(process.env.A3_FLAT_WORKSPACE);
assert.ok(!flatWorkspace || visualMatrix, 'Flat workspace qualification uses the visual matrix.');
const widths = visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]; const widths = visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440];
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : widths.flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode]))); const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : widths.flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/applications' : '../../docs/ui-modernisation/evidence/applications/browser'); const out = path.resolve(import.meta.dirname, flatWorkspace ? '../../docs/ui-modernisation/visual-correction/evidence/applications-flat' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/applications' : '../../docs/ui-modernisation/evidence/applications/browser');
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], aliases: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] }; const report = { screens: [], journeys: [], aliases: [], focusChecks: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch(); const browser = await chromium.launch();
try { try {
for (const [width, language, mode] of configurations) { for (const [width, language, mode] of configurations) {
@@ -55,6 +57,24 @@ try {
const search = page.getByRole('textbox', { name: language === 'en' ? 'Search' : 'Søk', exact: true }); await search.waitFor(); const search = page.getByRole('textbox', { name: language === 'en' ? 'Search' : 'Søk', exact: true }); await search.waitFor();
await page.locator('[data-job-row-id="42"], [data-job-card-id="42"]').first().waitFor(); await page.locator('[data-job-row-id="42"], [data-job-card-id="42"]').first().waitFor();
await scan('table'); await scan('table');
if (flatWorkspace) {
await page.keyboard.press('Tab');
const row = page.locator('[data-job-row-id="42"]').first(); await row.focus(); await expect(row).toBeFocused();
const focus = await row.evaluate(element => {
const style = getComputedStyle(element), rect = element.getBoundingClientRect();
const extension = parseFloat(style.outlineWidth) + parseFloat(style.outlineOffset);
const clippedBy = [];
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const bounds = parent.getBoundingClientRect(), overflow = getComputedStyle(parent).overflowX;
if (['hidden', 'clip', 'auto', 'scroll'].includes(overflow) && (rect.left - extension < bounds.left || rect.right + extension > bounds.right)) clippedBy.push(parent.tagName);
}
return { outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, clippedBy };
});
report.focusChecks.push({ width, language, mode, ...focus });
assert.notEqual(focus.outlineStyle, 'none');
if (width < 768) assert.deepEqual(focus.clippedBy, [], 'mobile row focus ring is clipped');
await scan('table-focused');
}
if (visualMatrix) { if (visualMatrix) {
await page.getByRole('button', { name: language === 'en' ? 'Add Job' : 'Legg til jobb', exact: true }).click(); await page.getByRole('button', { name: language === 'en' ? 'Add Job' : 'Legg til jobb', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible(); await scan('add-job'); await expect(page.getByRole('dialog')).toBeVisible(); await scan('add-job');
@@ -78,6 +98,11 @@ try {
const main = await page.getByRole('tabpanel').boundingBox(), side = await aside.boundingBox(); const main = await page.getByRole('tabpanel').boundingBox(), side = await aside.boundingBox();
assert.ok(width > 1000 ? side.x > main.x + main.width - 2 && Math.abs(side.y - main.y) < 2 : side.y >= main.y + main.height - 2); assert.ok(width > 1000 ? side.x > main.x + main.width - 2 && Math.abs(side.y - main.y) < 2 : side.y >= main.y + main.height - 2);
await scan('detail'); await scan('detail');
if (flatWorkspace) {
await page.getByRole('button', { name: language === 'en' ? 'Job details' : 'Stillingsdetaljer', exact: true }).click();
await expect(page.getByText('Recorded source advertisement.', { exact: true })).toBeVisible();
await scan('detail-expanded');
}
await page.getByRole('button', { name: language === 'en' ? 'Back to applications' : 'Tilbake til søknader', exact: true }).click(); await page.getByRole('button', { name: language === 'en' ? 'Back to applications' : 'Tilbake til søknader', exact: true }).click();
await page.waitForURL(/view=board/); assert.equal(new URL(page.url()).searchParams.get('q'), 'nurse'); await page.waitForURL(/view=board/); assert.equal(new URL(page.url()).searchParams.get('q'), 'nurse');
await page.locator('[data-kanban-card-id="42"] [role="button"]').waitFor(); await page.locator('[data-kanban-card-id="42"] [role="button"]').waitFor();
@@ -93,7 +118,7 @@ try {
} }
await context.close(); await context.close();
} }
assert.equal(report.screens.length, configurations.length * (visualMatrix ? 4 : 3)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.mockedWrites.length, configurations.length); assert.equal(report.aliases.length, visualMatrix ? 3 : 0); assert.equal(report.screens.length, configurations.length * (flatWorkspace ? 6 : visualMatrix ? 4 : 3)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.mockedWrites.length, configurations.length); assert.equal(report.aliases.length, visualMatrix ? 3 : 0);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []);
assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} controlled table/board/detail journeys,${report.screens.length} axe scans/captures;${report.mockedWrites.length} mocked status writes.`); console.log(`PASS:${report.journeys.length} controlled table/board/detail journeys,${report.screens.length} axe scans/captures;${report.mockedWrites.length} mocked status writes.`);
+25 -33
View File
@@ -673,15 +673,16 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
data-content-density={density} data-content-density={density}
sx={{ sx={{
mt: 1.5, mt: 1.5,
overflow: "hidden", overflow: isMobile ? "visible" : "hidden",
borderRadius: 1, borderRadius: isMobile ? 0 : 1,
border: "1px solid", border: isMobile ? 0 : "1px solid",
borderColor: "divider", borderColor: "divider",
backgroundColor: isMobile ? "transparent" : "background.paper",
boxShadow: "none", boxShadow: "none",
}} }}
> >
{isMobile ? ( {isMobile ? (
<Stack spacing="var(--app-list-gap)" sx={{ p: 1.25 }}> <Stack spacing={0}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} /> <FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
</Box> </Box>
@@ -690,7 +691,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, appliedDateLabel, isSelected }) => { {!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, appliedDateLabel, isSelected }) => {
const compactTags = tags.slice(0, 6); const compactTags = tags.slice(0, 6);
return ( return (
<Paper <Box
key={job.id} key={job.id}
data-job-row-id={job.id} data-job-row-id={job.id}
role={mode === "jobs" && !job.isDeleted ? "link" : undefined} role={mode === "jobs" && !job.isDeleted ? "link" : undefined}
@@ -705,12 +706,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
openJob(job.id); openJob(job.id);
} }
}} }}
variant="outlined"
sx={{ sx={{
p: "var(--app-list-item-padding)", py: "var(--app-list-item-padding)",
borderRadius: 2, borderBottom: "1px solid",
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default", cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
backgroundColor: isSelected ? "action.selected" : "background.paper", backgroundColor: isSelected ? "action.selected" : "transparent",
borderColor: "divider", borderColor: "divider",
boxShadow: "none", boxShadow: "none",
"&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined, "&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined,
@@ -733,7 +733,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{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} {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>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}> {actionSignals.length > 0 && <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
{actionSignals.map((signal) => ( {actionSignals.map((signal) => (
<Chip <Chip
key={`${job.id}-${signal.label}-${signal.detail}`} key={`${job.id}-${signal.label}-${signal.detail}`}
@@ -747,27 +747,27 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
sx={{ fontWeight: 700 }} sx={{ fontWeight: 700 }}
/> />
))} ))}
</Box> </Box>}
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
{columns.dateApplied ? ( {columns.dateApplied ? (
<Box> <Box sx={{ display: "flex", flexWrap: "wrap", columnGap: 0.5, alignItems: "baseline" }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography> <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{appliedDateLabel}</Typography> <Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{appliedDateLabel}</Typography>
</Box> </Box>
) : null} ) : null}
{columns.daysSince ? ( {columns.daysSince ? (
<Box> <Box sx={{ display: "flex", flexWrap: "wrap", columnGap: 0.5, alignItems: "baseline" }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDays")}</Typography> <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("jobTableDays")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>{job.daysSince}</Typography> <Typography variant="body2" sx={{ fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>{job.daysSince}</Typography>
</Box> </Box>
) : null} ) : null}
<Box> <Box sx={{ display: "flex", flexWrap: "wrap", columnGap: 0.5, alignItems: "baseline" }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableLocation")}</Typography> <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("jobTableLocation")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{job.location ?? "-"}</Typography> <Typography variant="body2" sx={{ fontWeight: 600 }}>{job.location ?? "-"}</Typography>
</Box> </Box>
<Box> <Box sx={{ display: "flex", flexWrap: "wrap", columnGap: 0.5, alignItems: "baseline" }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography> <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography> <Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
</Box> </Box>
</Box> </Box>
@@ -785,25 +785,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box> </Box>
) : null} ) : null}
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1, "& .MuiButton-root": { minHeight: 44, whiteSpace: "normal", lineHeight: 1.25 } }}> <Box sx={{ display: "flex", gap: 0.5, "& .MuiIconButton-root": { width: 44, height: 44 } }}>
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={() => setEditJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}> <Tooltip title={t("jobTableEdit")}><IconButton aria-label={t("jobTableEdit")} onClick={() => setEditJobId(job.id)}><EditOutlinedIcon /></IconButton></Tooltip>
{t("jobTableEdit")} <Tooltip title={t("jobTableQuickStatus")}><IconButton aria-label={t("jobTableQuickStatus")} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon /></IconButton></Tooltip>
</Button>
<Button variant="outlined" startIcon={<MoreHorizIcon />} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableQuickStatus")}
</Button>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? ( {(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
<Button variant="outlined" startIcon={<RestoreFromTrashOutlinedIcon />} onClick={() => void restore(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}> <Tooltip title={t("jobTableRestore")}><IconButton aria-label={t("jobTableRestore")} onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon /></IconButton></Tooltip>
{t("jobTableRestore")}
</Button>
) : ( ) : (
<Button color="error" variant="outlined" startIcon={<DeleteOutlineIcon />} onClick={() => void softDelete(job)} sx={{ minHeight: 42, fontWeight: 700 }}> <Tooltip title={t("jobTableSoftDelete")}><IconButton color="error" aria-label={t("jobTableSoftDelete")} onClick={() => void softDelete(job)}><DeleteOutlineIcon /></IconButton></Tooltip>
{t("jobTableSoftDelete")}
</Button>
)} )}
</Box> </Box>
</Stack> </Stack>
</Paper> </Box>
); );
})} })}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
@@ -51,6 +51,38 @@ test("row status/edit/delete controls retain selection and do not open the works
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs$/); 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 => {
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,
addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: () => true,
}) });
try {
localStorage.setItem("uiLanguage", language);
const copy = translations[language];
show("jobs", "?q=platform");
const row = await screen.findByRole("link", { name: copy.jobTableOpenJob.replace("{title}", job.jobTitle).replace("{company}", job.company.name) });
fireEvent.click(within(row).getByRole("checkbox"));
fireEvent.click(within(row).getByRole("button", { 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(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(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");
fireEvent.keyDown(row, { key: "Enter" });
expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs\/42$/);
expect(window.history.state.jobListFocusId).toBe(job.id);
} finally {
if (originalMedia) Object.defineProperty(window, "matchMedia", originalMedia);
else delete (window as Partial<Window>).matchMedia;
}
});
test("table sort, page-size and optional columns keep their existing independent contracts", async () => { test("table sort, page-size and optional columns keep their existing independent contracts", async () => {
show("jobs", "?q=platform&location=Troms%C3%B8"); show("jobs", "?q=platform&location=Troms%C3%B8");
await screen.findByRole("row", { name: /open platform engineer/i }); await screen.findByRole("row", { name: /open platform engineer/i });
@@ -4,7 +4,7 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { import {
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, IconButton, Paper, Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, IconButton,
Skeleton, Stack, Tab, Tabs, Tooltip, Typography, Skeleton, Stack, Tab, Tabs, Tooltip, Typography,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -218,14 +218,14 @@ export function ApplicationWorkspace({
return ( return (
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}> <Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<ViewStateNotice title={t("workspaceLoadFailed")} loading={loading && !overview} compact /> <ViewStateNotice title={t("workspaceLoadFailed")} loading={loading && !overview} compact />
<Paper variant="outlined" sx={{ minWidth: 0, borderRadius: 1 }}> <Box sx={{ minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}> <Stack direction="row" alignItems="center" spacing={0.5} sx={{ pt: 0.5 }}>
<Tooltip title={t("workspaceBack")}> <Tooltip title={t("workspaceBack")}>
<IconButton size="small" aria-label={t("workspaceBack")} onClick={close}> <IconButton size="small" aria-label={t("workspaceBack")} onClick={close}>
<ArrowBackIcon fontSize="small" /> <ArrowBackIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}> <Typography variant="caption" color="text.secondary">
{t("workspace")} {t("workspace")}
</Typography> </Typography>
{overview ? <Button size="small" onClick={() => contextTitle.current?.focus()} sx={{ ml: "auto" }}>{t("workspaceJumpContext")}</Button> : null} {overview ? <Button size="small" onClick={() => contextTitle.current?.focus()} sx={{ ml: "auto" }}>{t("workspaceJumpContext")}</Button> : null}
@@ -252,11 +252,11 @@ export function ApplicationWorkspace({
variant="scrollable" variant="scrollable"
scrollButtons="auto" scrollButtons="auto"
aria-label={t("workspaceSections")} aria-label={t("workspaceSections")}
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }} sx={{ borderBottom: 1, borderColor: "divider", minHeight: 46 }}
> >
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} id={`${sectionId}-${s.key}`} aria-controls={`${sectionId}-panel`} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)} {WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} id={`${sectionId}-${s.key}`} aria-controls={`${sectionId}-panel`} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
</Tabs> </Tabs>
</Paper> </Box>
<Box data-application-layout sx={{ display: "grid", gap: 3, minWidth: 0, alignItems: "start", ...(!onClose && overview ? { "@media (min-width:1001px)": { gridTemplateColumns: "minmax(0, 1fr) minmax(16rem, 20rem)" } } : {}) }}> <Box data-application-layout sx={{ display: "grid", gap: 3, minWidth: 0, alignItems: "start", ...(!onClose && overview ? { "@media (min-width:1001px)": { gridTemplateColumns: "minmax(0, 1fr) minmax(16rem, 20rem)" } } : {}) }}>
<Box ref={panelRef} role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}> <Box ref={panelRef} role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
@@ -288,22 +288,22 @@ export function ApplicationWorkspace({
/>} />}
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection key={jobId} jobId={jobId} />} {section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection key={jobId} jobId={jobId} />}
{section === "timeline" && subsection === "activity" && jobId > 0 && <ApplicationTimeline jobId={jobId} />} {section === "timeline" && subsection === "activity" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
{section === "timeline" && subsection === "correspondence" && jobId > 0 && <PageSection surface title={t("workspaceCommunication")} level="h3"><Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} /></PageSection>} {section === "timeline" && subsection === "correspondence" && jobId > 0 && <PageSection title={t("workspaceCommunication")} level="h3"><Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} /></PageSection>}
</Box> </Box>
</>} </>}
{section === "documents" && jobId > 0 && <> {section === "documents" && jobId > 0 && <>
<PageSection surface title={t("workspaceCurrentDocuments")} description={t("workspaceCurrentDocumentsHelp")} actions={<> <PageSection title={t("workspaceCurrentDocuments")} description={t("workspaceCurrentDocumentsHelp")} actions={<>
<Button onClick={() => go("application", "cv")}>{t("workspaceCv")}</Button> <Button onClick={() => go("application", "cv")}>{t("workspaceCv")}</Button>
<Button onClick={() => go("application", "letter")}>{t("workspaceCoverLetter")}</Button> <Button onClick={() => go("application", "letter")}>{t("workspaceCoverLetter")}</Button>
</>}><Attachments jobId={jobId} /></PageSection> </>}><Attachments jobId={jobId} /></PageSection>
<SubmittedPackagesSection key={jobId} jobId={jobId} allowCapture={false} /> <SubmittedPackagesSection key={jobId} jobId={jobId} allowCapture={false} />
</>} </>}
{section === "tasks" && jobId > 0 && <> {section === "tasks" && jobId > 0 && <>
<PageSection surface title={t("workspaceNotes")} actions={<Button startIcon={<EditOutlinedIcon />} onClick={() => setEditOpen(true)}>{t("workspaceEdit")}</Button>}> <PageSection title={t("workspaceNotes")} actions={<Button startIcon={<EditOutlinedIcon />} onClick={() => setEditOpen(true)}>{t("workspaceEdit")}</Button>}>
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview?.notes || t("workspaceNoNotes")}</Typography> <Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview?.notes || t("workspaceNoNotes")}</Typography>
</PageSection> </PageSection>
<PageSection title={t("workspaceChecklist")}><ApplicationChecklist jobId={jobId} onChanged={load} /></PageSection> <PageSection title={t("workspaceChecklist")}><ApplicationChecklist jobId={jobId} onChanged={load} /></PageSection>
<PageSection surface title={t("calendarTitle")}><ApplicationCalendarActions jobId={jobId} followUpAt={overview?.followUpAt ?? null} deadline={overview?.deadline ?? null} /></PageSection> <PageSection title={t("calendarTitle")}><ApplicationCalendarActions jobId={jobId} followUpAt={overview?.followUpAt ?? null} deadline={overview?.deadline ?? null} /></PageSection>
</>} </>}
</Box> </Box>
{overview ? <Box component="aside" aria-label={t("workspaceContext")} sx={{ minWidth: 0, ...(!onClose ? { "@media (min-width:1001px)": { borderInlineStart: "1px solid", borderColor: "divider", pl: 3 } } : {}) }}> {overview ? <Box component="aside" aria-label={t("workspaceContext")} sx={{ minWidth: 0, ...(!onClose ? { "@media (min-width:1001px)": { borderInlineStart: "1px solid", borderColor: "divider", pl: 3 } } : {}) }}>
@@ -358,7 +358,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
const { language, t } = useI18n(); const { language, t } = useI18n();
if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>; if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>;
return ( return (
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2 }}> <Box sx={{ pt: 0.5, pb: 1.5 }}>
<PageHeader level="h2" title={overview.jobTitle} description={overview.company ?? undefined} actions={<> <PageHeader level="h2" title={overview.jobTitle} description={overview.company ?? undefined} actions={<>
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEditApplication")}</Button> <Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEditApplication")}</Button>
{overview.jobUrl ? <Button component="a" href={overview.jobUrl} target="_blank" rel="noopener noreferrer" endIcon={<OpenInNewIcon />}>{t("workspaceOpenAdvert")}</Button> : null} {overview.jobUrl ? <Button component="a" href={overview.jobUrl} target="_blank" rel="noopener noreferrer" endIcon={<OpenInNewIcon />}>{t("workspaceOpenAdvert")}</Button> : null}
@@ -440,7 +440,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
return ( return (
<Stack spacing={2} sx={{ minWidth: 0 }}> <Stack spacing={2} sx={{ minWidth: 0 }}>
{overview.nextStep ? ( {overview.nextStep ? (
<PageSection surface title={t("workspaceNextAction")}> <PageSection title={t("workspaceNextAction")}>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography> <Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography> <Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography>
<Button variant="contained" endIcon={<ArrowForwardIcon />} <Button variant="contained" endIcon={<ArrowForwardIcon />}
@@ -455,11 +455,11 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
)} )}
<PageSection title={t("workspacePreparation")}> <PageSection title={t("workspacePreparation")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1 }}>
{stats.map((s) => ( {stats.map((s) => (
<Button key={s.label} variant="outlined" <Button key={s.label} variant="text"
onClick={() => onGo(s.go)} onClick={() => onGo(s.go)}
sx={{ p: 1.5, display: "block", textAlign: "left", color: "text.primary", borderColor: "divider", minWidth: 0, whiteSpace: "normal", overflowWrap: "anywhere" }}> sx={{ px: 0, py: 1, display: "block", textAlign: "left", color: "text.primary", borderBottom: 1, borderColor: "divider", borderRadius: 0, minWidth: 0, whiteSpace: "normal", overflowWrap: "anywhere" }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.secondary" }}> <Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.secondary" }}>
{s.icon} {s.icon}
<Typography variant="caption" sx={{ fontWeight: 700 }}>{s.label}</Typography> <Typography variant="caption" sx={{ fontWeight: 700 }}>{s.label}</Typography>
@@ -470,7 +470,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
</Box> </Box>
</PageSection> </PageSection>
<PageSection surface title={t("workspaceRecentActivity")} actions={<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>}> <PageSection title={t("workspaceRecentActivity")} actions={<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>}>
{overview.recentActivity.length === 0 ? ( {overview.recentActivity.length === 0 ? (
<Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography> <Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography>
) : ( ) : (
@@ -510,11 +510,11 @@ function OverviewDetails({ overview, onEdit }: { overview: WorkspaceOverview; on
return ( return (
<Box> <Box>
{panels.map((panel) => ( {panels.map((panel) => (
<Accordion key={panel.id} expanded={!!expanded[panel.id]} onChange={(_, open) => setExpanded((current) => ({ ...current, [panel.id]: open }))} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}> <Accordion key={panel.id} expanded={!!expanded[panel.id]} onChange={(_, open) => setExpanded((current) => ({ ...current, [panel.id]: open }))} disableGutters elevation={0} sx={{ borderTop: 1, borderBottom: 1, borderInline: 0, borderColor: "divider", borderRadius: 0, "&:first-of-type, &:last-of-type": { borderRadius: 0 }, "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
<AccordionSummary ref={(node: HTMLDivElement | null) => { triggers.current[panel.id] = node; }} expandIcon={<ExpandMoreIcon />} aria-controls={`${prefix}-${panel.id}-content`} id={`${prefix}-${panel.id}-header`}> <AccordionSummary ref={(node: HTMLDivElement | null) => { triggers.current[panel.id] = node; }} expandIcon={<ExpandMoreIcon />} aria-controls={`${prefix}-${panel.id}-content`} id={`${prefix}-${panel.id}-header`}>
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography> <Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails> <AccordionDetails sx={{ px: 0, pb: 2 }}>{panel.content}</AccordionDetails>
</Accordion> </Accordion>
))} ))}
</Box> </Box>
@@ -552,7 +552,7 @@ function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview |
]; ];
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Paper sx={{ p: 2.5, borderRadius: 3 }}> <Box sx={{ py: 1 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mb: 1.5 }}> <Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("workspaceApplicationInfo")}</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("workspaceApplicationInfo")}</Typography>
<Button size="small" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEdit")}</Button> <Button size="small" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEdit")}</Button>
@@ -576,9 +576,9 @@ function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview |
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview.notes}</Typography> <Typography variant="body2" sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview.notes}</Typography>
</Box> </Box>
) : null} ) : null}
</Paper> </Box>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}> <Box sx={{ py: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>{t("workspaceJobDescription")}</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>{t("workspaceJobDescription")}</Typography>
{!overview.hasJobDescription ? ( {!overview.hasJobDescription ? (
<Alert severity="warning" sx={{ borderRadius: 2 }} action={<Button color="inherit" size="small" onClick={onEdit}>{t("workspaceAddAdvert")}</Button>}> <Alert severity="warning" sx={{ borderRadius: 2 }} action={<Button color="inherit" size="small" onClick={onEdit}>{t("workspaceAddAdvert")}</Button>}>
@@ -600,7 +600,7 @@ function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview |
) : null} ) : null}
</Stack> </Stack>
)} )}
</Paper> </Box>
</Stack> </Stack>
); );
} }