feat(ui): modernise application tracking and right context

This commit is contained in:
cesnimda
2026-11-03 10:04:00 +01:00
parent dc93b589c9
commit 98e9502af5
65 changed files with 1543 additions and 28 deletions
@@ -201,6 +201,36 @@ test("opens a direct workspace URL and returns to applications", async () => {
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs"));
});
test.each([
['en', 'Application context', 'Jump to application context', 'Return to current task', 'Application'],
['nb', 'Søknadskontekst', 'Gå til søknadskontekst', 'Tilbake til gjeldende oppgave', 'Søknad'],
])('keeps %s context after the primary task with a keyboard jump and return across sections', async (language, contextName, jumpName, returnName, applicationName) => {
localStorage.setItem('uiLanguage', language);
renderTable('/jobs/42?section=match');
const context = await screen.findByRole('complementary', { name: contextName });
expect(within(context).getByText('nav', { exact: true })).toBeVisible();
const panel = screen.getByRole('tabpanel');
expect(panel.compareDocumentPosition(context) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: jumpName }));
expect(within(context).getByRole('heading', { name: contextName })).toHaveFocus();
fireEvent.click(within(context).getByRole('button', { name: returnName }));
expect(panel).toHaveFocus();
fireEvent.click(screen.getByRole('tab', { name: applicationName }));
expect(await screen.findByText('CV section')).toBeInTheDocument();
expect(screen.getByRole('complementary', { name: contextName })).toBeVisible();
});
test.each([
['en', 'No next action is recorded.', 'fully prepared'],
['nb', 'Ingen neste handling er registrert.', 'ferdig forberedt'],
])('does not infer %s application readiness from an absent next action', async (language, message, falseClaim) => {
localStorage.setItem('uiLanguage', language);
renderTable('/jobs/42');
const feedback = await screen.findByRole('alert');
expect(feedback).toHaveTextContent(message);
expect(feedback).not.toHaveTextContent(falseClaim);
});
test("row controls do not trigger navigation", async () => {
renderTable();
@@ -318,9 +348,9 @@ test("keeps the server-selected next action and preparation entry on the existin
test("uses recorded dates in the summary and keeps absent dates unknown", async () => {
renderTable("/jobs/42");
const summary = await screen.findByRole("region", { name: "Application information" });
const summary = await screen.findByRole("complementary", { name: "Application context" });
expect(summary).toHaveTextContent(new Date(overview.dateApplied).toLocaleDateString("en"));
expect(within(summary).getAllByText("—")).toHaveLength(2);
expect(within(summary).getAllByText("—")).toHaveLength(3); // Salary, deadline and follow-up remain unrecorded.
expect(screen.queryByRole("link", { name: "Open original advert" })).not.toBeInTheDocument();
});
+5 -5
View File
@@ -491,7 +491,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
) : null}
{isMobile ? (
<Paper variant="outlined" sx={{ mt: 2, p: 1.25, borderRadius: 2.5, boxShadow: "none" }}>
<Box sx={{ mt: 2, pb: 2, borderBottom: 1, borderColor: "divider", minWidth: 0 }}>
<Stack spacing={1.1}>
<TextField
label={t("jobTableSearch")}
@@ -572,9 +572,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
{!boardView ? densityControl : null}
</Stack>
</Paper>
</Box>
) : (
<Paper variant="outlined" sx={{ display: "flex", gap: 1.5, alignItems: "center", mt: 2, p: 1.5, flexWrap: "wrap", borderRadius: 2.5, boxShadow: "none" }}>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mt: 2, pb: 2, flexWrap: "wrap", borderBottom: 1, borderColor: "divider", minWidth: 0 }}>
<TextField
label={t("jobTableSearch")}
value={search}
@@ -627,7 +627,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{!boardView && !isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
{!boardView ? densityControl : null}
</Box>
</Paper>
</Box>
)}
{!boardView && selectedIds.length > 0 ? (
@@ -674,7 +674,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
sx={{
mt: 1.5,
overflow: "hidden",
borderRadius: 2.5,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
boxShadow: "none",
@@ -214,6 +214,9 @@ export default function KanbanBoard({ refreshToken = 0, onResultCount }: { refre
{!jobsResource.loading && !jobsResource.refreshing && !jobsResource.error ? (
<Box
role="region"
aria-label={t("jobsBoardView")}
tabIndex={0}
sx={{
display: { xs: "flex", lg: "grid" },
width: "100%",
@@ -226,6 +229,7 @@ export default function KanbanBoard({ refreshToken = 0, onResultCount }: { refre
scrollSnapType: { xs: "x mandatory", lg: "none" },
pb: { xs: 1, lg: 0 },
WebkitOverflowScrolling: "touch",
"&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: -3 },
}}
>
{columns.map(({ key, label, droppable }) => {
@@ -260,7 +264,7 @@ export default function KanbanBoard({ refreshToken = 0, onResultCount }: { refre
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
sx={{
p: 1.25,
borderRadius: 2.5,
borderRadius: 1,
minHeight: 220,
flex: { xs: "0 0 min(85vw, 340px)", lg: "none" },
scrollSnapAlign: { xs: "start", lg: "none" },
@@ -269,7 +273,7 @@ export default function KanbanBoard({ refreshToken = 0, onResultCount }: { refre
outline: isActiveTarget ? "2px solid" : "none",
outlineColor: droppable ? "primary.main" : "error.main",
outlineOffset: 2,
backgroundColor: isActiveTarget && droppable ? "action.hover" : "background.paper",
backgroundColor: isActiveTarget && droppable ? "action.hover" : "background.default",
transition: "background-color .15s, border-color .15s, outline-color .15s",
"&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: 2 },
}}
@@ -316,11 +320,11 @@ export default function KanbanBoard({ refreshToken = 0, onResultCount }: { refre
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
sx={{
cursor: "grab",
borderRadius: 2,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
borderLeft: `3px solid ${cardColor}`,
backgroundColor: "background.elevated",
borderInlineStart: `3px solid ${cardColor}`,
backgroundColor: "background.paper",
boxShadow: "none",
transition: "border-color .15s, background-color .15s",
outline: dragJobId === j.id ? "2px solid" : "none",
+8 -2
View File
@@ -1411,6 +1411,9 @@ export const translations = {
workspaceApplied: "Applied",
workspaceNotScheduled: "Not scheduled",
workspaceApplicationInfo: "Application information",
workspaceContext: "Application context",
workspaceJumpContext: "Jump to application context",
workspaceReturnTask: "Return to current task",
workspaceEdit: "Edit",
workspaceNotes: "Notes",
workspaceJobDescription: "Job description",
@@ -3059,7 +3062,7 @@ export const translations = {
workspaceCustomProgress: "This custom status has no standard pipeline position.",
workspacePreparation: "Application preparation",
workspaceNextAction: "Next recommended action",
workspaceNothingOutstanding: "Nothing outstanding — this application is fully prepared.",
workspaceNothingOutstanding: "No next action is recorded. Review your application preparation below.",
workspaceRecentActivity: "Recent activity",
workspaceRefresh: "Refresh",
workspaceNoActivity: "No activity recorded yet.",
@@ -4563,6 +4566,9 @@ export const translations = {
workspaceApplied: "Søkt",
workspaceNotScheduled: "Ikke planlagt",
workspaceApplicationInfo: "Søknadsinformasjon",
workspaceContext: "Søknadskontekst",
workspaceJumpContext: "Gå til søknadskontekst",
workspaceReturnTask: "Tilbake til gjeldende oppgave",
workspaceEdit: "Rediger",
workspaceNotes: "Notater",
workspaceJobDescription: "Stillingsbeskrivelse",
@@ -6211,7 +6217,7 @@ export const translations = {
workspaceCustomProgress: "Denne egendefinerte statusen har ingen plass i standardløpet.",
workspacePreparation: "Forberedelse av søknaden",
workspaceNextAction: "Neste anbefalte handling",
workspaceNothingOutstanding: "Ingenting gjenstår — denne søknaden er ferdig forberedt.",
workspaceNothingOutstanding: "Ingen neste handling er registrert. Se gjennom forberedelsene til søknaden nedenfor.",
workspaceRecentActivity: "Nylig aktivitet",
workspaceRefresh: "Oppdater",
workspaceNoActivity: "Ingen aktivitet er registrert ennå.",
@@ -84,6 +84,7 @@ export function ApplicationWorkspace({
const sectionId = useId();
const secondaryId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const contextTitle = useRef<HTMLHeadingElement>(null);
const [loadedOverview, setOverview] = useState<WorkspaceOverview | null>(null);
const overview = loadedOverview?.id === jobId ? loadedOverview : null;
@@ -227,6 +228,7 @@ export function ApplicationWorkspace({
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
{t("workspace")}
</Typography>
{overview ? <Button size="small" onClick={() => contextTitle.current?.focus()} sx={{ ml: "auto" }}>{t("workspaceJumpContext")}</Button> : null}
{fullPageHref ? (
<Tooltip title={t("workspaceOpenFull")}>
<IconButton
@@ -256,6 +258,7 @@ export function ApplicationWorkspace({
</Tabs>
</Paper>
<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 }}>
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
@@ -303,6 +306,10 @@ export function ApplicationWorkspace({
<PageSection surface title={t("calendarTitle")}><ApplicationCalendarActions jobId={jobId} followUpAt={overview?.followUpAt ?? null} deadline={overview?.deadline ?? null} /></PageSection>
</>}
</Box>
{overview ? <Box component="aside" aria-label={t("workspaceContext")} sx={{ minWidth: 0, ...(!onClose ? { "@media (min-width:1001px)": { borderInlineStart: "1px solid", borderColor: "divider", pl: 3 } } : {}) }}>
<WorkspaceContext overview={overview} titleRef={contextTitle} onReturn={() => panelRef.current?.focus()} />
</Box> : null}
</Box>
<EditJobDialog
open={editOpen}
jobId={jobId > 0 ? jobId : null}
@@ -312,6 +319,41 @@ export function ApplicationWorkspace({
</Box>
);
}
function WorkspaceContext({ overview, titleRef, onReturn }: { overview: WorkspaceOverview; titleRef: React.Ref<HTMLHeadingElement>; onReturn: () => void }) {
const { language, t } = useI18n();
return <PageSection title={t("workspaceContext")} titleRef={titleRef}>
<Box component="dl" sx={{ m: 0, display: "grid", gap: 2, minWidth: 0 }}>
{[
[t("company"), overview.company],
[t("location"), overview.location],
[t("workspaceCountry"), overview.countryCode],
[t("workspaceSource"), overview.source],
[t("jobDetailsSalary"), overview.salary],
].map(([label, value]) => <Box key={label}>
<Typography component="dt" variant="caption" color="text.secondary">{label}</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0, overflowWrap: "anywhere" }}>{value || "—"}</Typography>
</Box>)}
{[
[t("workspaceDiscovered"), overview.savedAt],
[t("workspaceApplied"), overview.dateApplied],
[t("jobDetailsDeadline"), overview.deadline],
[t("intelligenceCategoryFollowUp"), overview.followUpAt],
].map(([label, date]) => {
// Date-only deadlines are local calendar days, not midnight UTC instants.
const parsed = date ? new Date(/^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T00:00:00` : date) : null;
return <Box key={label}>
<Typography component="dt" variant="caption" color="text.secondary">{label}</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0, mt: 0.5, fontWeight: 600 }}>
{parsed && !Number.isNaN(parsed.valueOf()) ? <time dateTime={date!}>{parsed.toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</time> : "—"}
</Typography>
</Box>;
})}
</Box>
{overview.tags.length ? <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>{overview.tags.map(tag => <Chip key={tag} size="small" label={tag} sx={{ maxWidth: "100%", height: "auto", "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }} />)}</Box> : null}
<Button onClick={onReturn}>{t("workspaceReturnTask")}</Button>
</PageSection>;
}
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
const { language, t } = useI18n();
if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>;
@@ -397,19 +439,6 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
return (
<Stack spacing={2} sx={{ minWidth: 0 }}>
<PageSection surface title={t("workspaceApplicationInfo")}>
<Box component="dl" sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 2 }}>
{[
[t("workspaceDiscovered"), overview.savedAt],
[t("workspaceApplied"), overview.dateApplied],
[t("jobDetailsDeadline"), overview.deadline],
[t("intelligenceCategoryFollowUp"), overview.followUpAt],
].map(([label, date]) => <Box key={label}>
<Typography component="dt" variant="caption" color="text.secondary">{label}</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0, mt: 0.5, fontWeight: 600 }}>{date ? new Date(date).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"}</Typography>
</Box>)}
</Box>
</PageSection>
{overview.nextStep ? (
<PageSection surface title={t("workspaceNextAction")}>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
@@ -420,7 +449,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
</Button>
</PageSection>
) : (
<Alert severity="success" sx={{ borderRadius: 3 }}>
<Alert severity="info" sx={{ borderRadius: 1 }}>
{t("workspaceNothingOutstanding")}
</Alert>
)}