feat(ui): modernise application tracking and right context
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user