feat(dashboard): customise widget visibility order and size

This commit is contained in:
cesnimda
2026-10-21 21:07:37 +02:00
parent 68ee6cacb0
commit 77968da90c
9 changed files with 423 additions and 120 deletions
+4 -4
View File
@@ -45,10 +45,10 @@ SQLite and MariaDB use provider-neutral EF mapping and conditional updates. Expl
## Checkpoints and TODO
- [ ] P8-00: baseline suites, catalogue, persistence and prerequisite review; commit/push plan.
- [ ] P8-01: editing, visibility, draft/cancel, empty recovery, localisation; focused component tests and checkpoint commit/push.
- [ ] P8-02: existing drag helper, named move buttons, DOM ordering and discrete grid sizing; normalisation/focus tests and checkpoint commit/push.
- [ ] P8-03: additive account storage, validated endpoint, revision conflict, retry/reset/session isolation; backend/frontend integration proof and checkpoint commit/push.
- [x] P8-00: baseline suites, catalogue, persistence and prerequisite review; commit/push plan.
- [x] P8-01: editing, visibility, draft/cancel, empty recovery, localisation; focused component tests and shared P8-01/02 checkpoint commit/push.
- [x] P8-02: existing drag helper, named move buttons, DOM ordering and discrete grid sizing; normalisation/focus tests and shared P8-01/02 checkpoint commit/push.
- [x] P8-03: additive account storage, validated endpoint, revision conflict, retry/reset/session isolation; backend/frontend integration proof and checkpoint commit/push.
- [ ] P8-04: actual browser desktop/tablet/mobile, EN/NB, themes, keyboard, failures and axe; inspect captures, retain human qualifications; commit/push.
- [ ] P8-05: full backend/frontend/browser, separate standard contracts/sidecar, types/lint/build/security; 57-group reconciliation, final decisions and handoff; commit/push and direct remote verification.
+18
View File
@@ -0,0 +1,18 @@
# P8-01 — Editing and visibility
Implemented an explicit inline editing mode using existing MUI/PageSection/confirmation/toast patterns. All nine existing report sections are configurable. Visibility updates only the draft preview; Save waits for the owner-only API acknowledgement; Cancel confirms a dirty discard and never writes. Every widget may be hidden, with an intentional empty message and the persistent Customise action. Existing onboarding/new-account guidance stays reachable. All new copy is English/Bokmål.
The typed catalogue and persistence hook are one shared prerequisite; visibility/order/size controls are one coherent editor. P8-01 and P8-02 share that frontend source commit rather than manufacture an intermediate editor that cannot represent its saved server contract. Backend storage was implemented first to make the first real Save safe. This is a checkpoint ordering/grouping deviation, not additional scope.
## Fresh evidence
- Original five new editor cases failed against the immediate-menu implementation (`editor-red.log`). Integrated focused run now passes **36 tests / 6 suites** (`dashboard-final.json`): Dashboard presentation, Analytics presentation, daily control loop, catalogue, layout hook and six editor cases. Zero failed/skipped in that run; the full frontend remains a separate final lane.
- Existing exact monthly values, denominators, custom status/company destinations, independent summary/trends errors and period loading remain asserted. Original five-flag immediate local-write test now asserts all nine choices through explicit Save and remount with persisted server readback fixture.
- Catalogue/hook tests exercise corrupted/unknown/duplicate IDs, unsupported sizes, missing new widgets appended hidden, legacy owner-only flags, failed save/retry, unsupported future version, 409/reload and late responses after identity change/unmount. Mocked component API responses are not cross-device HTTP evidence.
- Corrected real browser run passed **10/10**, zero skipped/retried/flaky (`browser-corrected.json`): two retained Dashboard journeys plus eight new Phase 8 cases. Actual SQLite/API/cookies/CSRF verify saved readback, fresh browser login, logout/login, two-session conflict, isolation, reset/cancel, failure/retry and all-hidden recovery. The initial prebuilt bundle targeted port5202 instead of the isolated5302 API; its 10 failures are retained in `browser-focused.json`. Rebuild with `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api` corrected the harness without relaxing assertions.
## Review repairs and conditions
Review reproduced pending-save navigation offering an untrue discard promise. A deferred-PUT test failed first, then passed after the guard began holding SPA navigation with a truthful wait status during saving. Aborting a browser request is never represented as a server rollback. A browser unload warning cannot guarantee save cancellation.
Screenshots exposed edit-entry focus scrolling the heading beneath the fixed header. An actual browser geometry regression failed against that bundle; local scroll-margin plus deliberate focus/scroll is now implemented, with the succeeding full browser run pending. New controls preserve the app's focus ring, native selects and 44px pointer controls. Final responsive, axe and human qualification is recorded in P8-04 rather than inferred from unit tests.
+11
View File
@@ -0,0 +1,11 @@
# P8-02 — Ordering and supported sizes
The existing `useDragReorder` provides desktop handle drag and an explicit drop-target border. The dedicated handle prevents ordinary widget actions from starting a drag. Named Earlier/Later buttons are the keyboard and touch alternative. Stable React keys, DOM order, a live position announcement and focus retention cover moving the focused control to the first/last position. No new drag library or dependency was added.
All nine IDs use one canonical order, including hidden widgets. Hiding preserves size and position. Summary cards support full width only; the other eight report sections support standard/full. The existing report nodes and accessible values tables render inside a two-column CSS grid at `md` and larger. Full width spans both columns. Below `md`, every widget uses one readable full-width column; saved widths remain unchanged. No dense packing, CSS visual-only order, fixed heights or arbitrary pixels.
Catalogue normalisation preserves known order, ignores unknown/duplicate IDs, falls back from unsupported sizes and appends absent entries hidden for saved revisions. The server separately rejects invalid writes, rather than trusting the frontend. The [catalogue](../README.md) names all defaults/data owners.
Integrated **36/36** focused frontend tests and corrected **10/10** real browser cases include order/size/visibility readback, native handle drag, keyboard focus/status, emulated touch button movement and zero page overflow at320/390/768/1440px in both themes/languages. Source evidence: `.artifacts/phase8/dashboard-final.json`, `browser-corrected.json`. The shared editor source is committed with P8-01 as documented there. Later fixed-header focus repair and full-regression results belong to final acceptance.
Physical touchscreen, software keyboard, human screen reader, complete true browser zoom, other-browser and production render qualification remain unperformed. Emulation does not close these requirements.
@@ -0,0 +1,120 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useBlocker, useBeforeUnload } from "react-router-dom";
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
import { useDragReorder } from "../hooks/useDragReorder";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
export function DashboardNavigationGuard({ dirty, saving }: { dirty: boolean; saving: boolean }) {
const { t } = useI18n();
const { confirm } = useConfirm();
const blocker = useBlocker(dirty || saving);
const [waiting, setWaiting] = useState(false);
const pending = useRef(false);
useBeforeUnload(React.useCallback(event => {
if (!dirty) return;
event.preventDefault(); event.returnValue = "";
}, [dirty]));
useEffect(() => {
if (blocker.state !== "blocked") { pending.current = false; return; }
if (saving) { setWaiting(true); blocker.reset(); return; }
if (pending.current) return;
pending.current = true;
void confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), cancelLabel: t("workspaceKeepEditing"), destructive: true })
.then(approved => { if (approved) blocker.proceed(); else blocker.reset(); });
}, [blocker, confirm, saving, t]);
return waiting && saving ? <Typography role="status">{t("dashboardWaitForSave")}</Typography> : null;
}
type Props = {
draft: DashboardLayout;
dirty: boolean;
saving: boolean;
saveError: string | null;
conflict: boolean;
onChange: (next: DashboardLayout) => void;
onSave: () => void;
onCancel: () => void;
onReload: () => void;
};
export default function DashboardLayoutEditor({ draft, dirty, saving, saveError, conflict, onChange, onSave, onCancel, onReload }: Props) {
const { t } = useI18n();
const { confirm } = useConfirm();
const panel = useRef<HTMLDivElement>(null);
const pendingFocus = useRef<{ id: string; direction: number } | null>(null);
const [announcement, announce] = useState("");
useEffect(() => {
panel.current?.focus({ preventScroll: true });
panel.current?.scrollIntoView?.({ block: "start" });
}, []);
useLayoutEffect(() => {
const target = pendingFocus.current;
if (!target) return;
pendingFocus.current = null;
const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`);
const preferred = row?.querySelector<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
(preferred ?? row?.querySelector<HTMLButtonElement>("button[data-move]:not(:disabled)"))?.focus();
}, [draft]);
const reorder = (from: number, to: number) => {
if (saving || from === to || to < 0 || to >= draft.widgets.length) return;
const widgets = [...draft.widgets];
const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved);
onChange({ ...draft, widgets });
const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey);
announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length }));
};
const drag = useDragReorder(reorder);
const change = (id: string, patch: Partial<DashboardLayout["widgets"][number]>) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) });
const cancel = async () => {
if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel();
};
const reset = async () => {
if (await confirm({ title: t("dashboardReset"), message: t("dashboardResetBody"), confirmLabel: t("dashboardReset") })) onChange({ ...defaultDashboardLayout(), revision: draft.revision });
};
const reload = async () => {
if (await confirm({ title: t("dashboardReload"), message: t("dashboardReloadBody"), confirmLabel: t("dashboardReload"), destructive: true })) onReload();
};
return <Box ref={panel} tabIndex={-1} role="group" aria-label={t("dashboardEditing")} sx={{ scrollMarginTop: "calc(var(--app-shell-header-height, 124px) + 16px)" }} onKeyDown={event => {
if (event.key === "Escape" && !saving && !event.defaultPrevented) { event.preventDefault(); void cancel(); }
}}>
<PageSection surface title={t("dashboardEditing")} description={t("dashboardEditingBody")}>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} sx={{ mb: 2 }}>
<Button variant="contained" disabled={saving || conflict} onClick={onSave}>{t(saving ? "dashboardSaving" : "dashboardSave")}</Button>
<Button disabled={saving} onClick={() => void cancel()}>{t("cancel")}</Button>
<Button disabled={saving} onClick={() => void reset()}>{t("dashboardReset")}</Button>
</Stack>
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
</Alert> : null}
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
{draft.widgets.map((widget, index) => {
const entry = WIDGETS.find(item => item.id === widget.id)!;
const name = t(entry.titleKey);
const gesture = drag.getItemProps(index);
return <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
</Stack>
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
onChange={event => change(widget.id, { size: event.target.value as "standard" | "full" })}
slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}>
{entry.sizes.map(size => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
</TextField>
</Box>;
})}
</Box>
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
</PageSection>
</Box>;
}
+94 -111
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
import React, { useCallback, useContext, useMemo, useRef, useState } from "react";
import { Link as RouterLink, useNavigate, useSearchParams, UNSAFE_DataRouterContext } from "react-router-dom";
import {
Box,
Button,
Menu,
MenuItem,
Alert,
Stack,
Link,
Table, TableBody, TableCell, TableHead, TableRow,
@@ -13,13 +12,14 @@ import {
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
import { useDashboardLayout } from "../hooks/useDashboardLayout";
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
import { useOptionalToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel, statusTone } from "../pipeline";
import { PageSection } from "../layout/PageLayout";
@@ -52,32 +52,6 @@ type OverviewAnalytics = {
};
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
type Prefs = {
cards: boolean;
activity: boolean;
funnel: boolean;
companies: boolean;
skills: boolean;
};
function prefsKey() {
return `dashboardPrefs:${getUserKeyFromToken()}`;
}
function loadPrefs(): Prefs {
try {
const raw = window.localStorage.getItem(prefsKey());
if (!raw) return { cards: true, activity: true, funnel: true, companies: true, skills: true };
return { cards: true, activity: true, funnel: true, companies: true, skills: true, ...JSON.parse(raw) };
} catch {
return { cards: true, activity: true, funnel: true, companies: true, skills: true };
}
}
function savePrefs(next: Prefs) {
window.localStorage.setItem(prefsKey(), JSON.stringify(next));
}
// "Needs your attention" row accent -- red for overdue follow-ups, amber for interview prep,
// blue/info for everything else (package work, readiness review). No "positive/offer" tier
// exists in the real reminder feed (see DashboardView report), so only these three appear.
@@ -103,9 +77,21 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
next.set("months", String(value));
setQuery(next);
};
const [dashboardPrefs, setPrefs] = useState<Prefs>(() => reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : loadPrefs());
const prefs = reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : dashboardPrefs;
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
const layoutState = useDashboardLayout(!reporting);
const activeLayout = reporting ? defaultDashboardLayout() : layoutState.draft ?? layoutState.layout;
const prefs = Object.fromEntries(activeLayout.widgets.map(widget => [widget.id, widget.visible]));
const dataRouter = useContext(UNSAFE_DataRouterContext);
const customizeButton = useRef<HTMLButtonElement>(null);
const optionalToast = useOptionalToast();
const [savedMessage, setSavedMessage] = useState(false);
const cancelLayout = () => { layoutState.cancelEditing(); requestAnimationFrame(() => customizeButton.current?.focus()); };
const saveLayout = async () => {
if (await layoutState.save()) {
setSavedMessage(true);
optionalToast?.toast(t("dashboardSaved"), "success");
requestAnimationFrame(() => customizeButton.current?.focus());
}
};
const loadSummary = useCallback(async () => {
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
api.get<JobStats>("/jobapplications/stats"),
@@ -177,14 +163,6 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
const monthSpan = analytics.length ? `${monthLabel(analytics[0].month)} – ${monthLabel(analytics[analytics.length - 1].month)}` : t("dashboardNoWindowData");
const stageCount = (stage: string) => overview?.funnel.find(item => item.label === stage)?.count ?? 0;
const togglePref = useCallback((key: keyof Prefs) => {
setPrefs(current => {
const next = { ...current, [key]: !current[key] };
savePrefs(next);
return next;
});
}, []);
// Both series share a zero baseline and maximum; independent scaling implies false equality.
const chart = useMemo(() => {
const max = Math.max(1, ...analytics.flatMap(point => [point.applied, point.responses]));
@@ -197,7 +175,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
<Box sx={{ width: `${Math.max(0, Math.min(100, value / Math.max(1, max) * 100))}%`, height: "100%", bgcolor: color, borderRadius: 1 }} />
</Box>;
const detailsSx = { mt: 2, "& summary": { cursor: "pointer", color: "text.primary", fontWeight: 600, py: 1 }, "& th, & td": { px: 1, py: 0.75, overflowWrap: "anywhere" } };
const prefsButton = <Button variant="outlined" startIcon={<TuneIcon />} aria-haspopup="menu" aria-expanded={Boolean(prefsAnchor)} onClick={event => setPrefsAnchor(event.currentTarget)}>{t("dashboardCustomize")}</Button>;
const prefsButton = !layoutState.editing ? <Button ref={customizeButton} variant="outlined" startIcon={<TuneIcon />} disabled={layoutState.loading || Boolean(layoutState.loadError)} onClick={() => { setSavedMessage(false); layoutState.startEditing(); }}>{t("dashboardCustomize")}</Button> : null;
const pipelineReport = <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
<Box sx={reporting ? grid : undefined}>
<Stack spacing={1.5}>
@@ -219,35 +197,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Box>
</PageSection>;
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
</Box>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{([
["cards", "dashboardSummaryCards"], ["activity", "dashboardActivityChart"],
["funnel", "dashboardConversionFunnel"], ["companies", "dashboardTopCompanies"], ["skills", "dashboardSkillsInsights"],
] as const).map(([key, label]) => (
<MenuItem key={key} role="menuitemcheckbox" aria-checked={prefs[key]} onClick={() => togglePref(key)}>
{prefs[key] ? <CheckBoxIcon aria-hidden sx={{ m: 1, color: "primary.main" }} /> : <CheckBoxOutlineBlankIcon aria-hidden sx={{ m: 1, color: "text.secondary" }} />}
{t(label)}
</MenuItem>
))}
</Menu>
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
</PageSection> : null}
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
{!reporting && summaryReady && hasJobs && prefs.cards ? (
<Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
const reports: Record<WidgetId, React.ReactNode> = {
cards: summaryReady && hasJobs ? <Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
{[
{ label: t("dashboardTrackedJobs"), value: stats!.active, caption: t("dashboardTrackedBody"), to: "/jobs" },
{ label: t("dashboardApplied30Days"), value: stats!.appliedLast30Days, caption: t("dashboardRecordedDates") },
@@ -260,11 +211,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Typography>
<Typography component="dd" variant="caption" color="text.secondary" sx={{ m: 0 }}>{item.caption}</Typography>
</Box>)}
</Box>
) : null}
{!reporting && summaryReady && hasJobs ? <Box sx={grid}>
{!reporting ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
</Box> : null,
attention: summaryReady && hasJobs ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionSample", { count: number(reminderJobs.length), shown: number(Math.min(5, reminderJobs.length)) })}</Typography>
<Typography variant="caption" component="p" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionBreakdown", {
followUp: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "follow-up").length),
@@ -285,20 +233,9 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
})}
{!reminderJobs.length ? <Typography color="text.secondary">{t("remindersNothing")}</Typography> : null}
</Stack>
</PageSection> : null}
{prefs.funnel ? pipelineReport : null}
</Box> : null}
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
</Box> : null}
{trendsReady && (reporting || hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
</PageSection> : null,
funnel: summaryReady && hasJobs ? pipelineReport : null,
activity: trendsReady && (reporting || hasJobs || hasActivity) ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
<Typography variant="body2" color="text.secondary">{monthSpan}</Typography>
{totalApplied > 0 ? <Typography variant="body2">{t("dashboardResponseSample", { responses: number(totalResponses), total: number(totalApplied) })} ({number(totalResponses / totalApplied * 100)}%)</Typography> : null}
@@ -322,25 +259,20 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
<TableBody>{analytics.map(point => <TableRow key={point.month}><TableCell component="th" scope="row">{monthLabel(point.month)}</TableCell><TableCell>{number(point.applied)}</TableCell><TableCell>{number(point.responses)}</TableCell></TableRow>)}</TableBody>
</Table>
</Box>
</PageSection> : null}
{reporting && summaryReady && hasJobs ? pipelineReport : null}
{summaryReady && hasJobs ? <Box sx={grid}>
{(overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
</PageSection> : null,
stageAge: summaryReady && hasJobs && (overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
<Stack spacing={1.5}>{overview!.timeInStage!.map(item => <Box key={item.stage}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}><Typography variant="body2">{statusLabel(t, item.stage)}</Typography><Typography variant="body2">{t("dashboardTimeInStageValue", { days: number(item.medianDays), count: number(item.count) })}</Typography></Box>
{bar(item.medianDays, Math.max(1, ...overview!.timeInStage!.map(stage => stage.medianDays)), "info.main")}
</Box>)}</Stack>
</PageSection> : null}
{(overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
</PageSection> : null,
sources: summaryReady && hasJobs && (overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
<Stack spacing={1.5}>{overview!.responseRateBySource.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{item.label}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.rate)}%</Typography></Box>
<Typography variant="caption" color="text.secondary">{t("dashboardRecordedResponseSample", { responses: number(item.responses), total: number(item.total) })}</Typography>
</Box>)}</Stack>
</PageSection> : null}
</Box> : null}
{summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
</PageSection> : null,
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{overview!.salaryInsights!.map(item => {
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
@@ -351,17 +283,15 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Box>;
})}
</Box>
</PageSection> : null}
<Box sx={grid}>
{summaryReady && hasJobs && prefs.companies ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
</PageSection> : null,
companies: summaryReady && hasJobs ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
<Stack spacing={1.5}>{(overview?.topCompanies ?? []).map(item => <Box key={item.companyId} sx={{ borderBottom: "1px solid", borderColor: "divider", pb: 1.5 }}>
<Link component={RouterLink} to={`/jobs?companyId=${item.companyId}`} sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>{item.company}</Link>
<Typography variant="body2" color="text.secondary">{t("dashboardCompanyJobsResponses", { jobs: number(item.count), responses: number(item.responses) })} ({number(item.responseRate)}%)</Typography>
</Box>)}</Stack>
{!overview?.topCompanies.length ? <Typography color="text.secondary">{t("companiesEmpty")}</Typography> : null}
</PageSection> : null}
{trendsReady && (reporting || hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
</PageSection> : null,
skills: trendsReady && (reporting || hasJobs || tags.length > 0) ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
<Stack spacing={1.5}>{tags.slice(0, reporting ? 10 : 8).map(tag => <Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{tag.tag}</Typography><Typography variant="body2">{number(tag.count)}</Typography></Box>
{bar(tag.count, Math.max(1, ...tags.map(item => item.count)), "primary.main")}
@@ -388,8 +318,61 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Table>
</Box>)}
</Box>
</PageSection> : null}
</PageSection> : null,
};
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
</Box>
{!reporting && layoutState.editing && layoutState.draft ? <>
<DashboardLayoutEditor draft={layoutState.draft} dirty={layoutState.dirty} saving={layoutState.saving || layoutState.loading}
saveError={layoutState.saveError} conflict={layoutState.conflict} onChange={layoutState.setDraft}
onSave={() => void saveLayout()} onCancel={cancelLayout} onReload={() => void layoutState.reload()} />
{dataRouter ? <DashboardNavigationGuard dirty={layoutState.dirty} saving={layoutState.saving} /> : null}
</> : null}
{!reporting && layoutState.loadError ? <Alert severity="warning" action={<Button onClick={() => void layoutState.reload()}>{t("retry")}</Button>}>
{t(layoutState.loadError === "unsupportedVersion" ? "dashboardUnsupportedVersion" : "dashboardLoadFailed")}
</Alert> : null}
{!reporting && savedMessage ? <Typography role="status">{t("dashboardSaved")}</Typography> : null}
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
</PageSection> : null}
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
</Box> : null}
{reporting ? <>
{reports.activity}
{reports.funnel}
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
{reports.salary}
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
</> : <Box aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
const content = reports[widget.id];
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
</Box>;
})}
</Box>}
</Box>
);
}
@@ -0,0 +1,94 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { ConfirmProvider } from './confirm';
import { api } from './api';
const mocked = api as jest.Mocked<typeof api>;
let stored: any;
const stats = { total: 2, active: 2, byStatus: { Applied: 2 }, appliedLast30Days: 2 };
function response(url: string) {
if (url.endsWith('/preferences/dashboard')) return stored;
if (url.endsWith('/stats')) return stats;
if (url.endsWith('/analytics-overview')) return { funnel: [{ label: 'Applied', count: 2 }], totalActive: 2, totalResponses: 0, responseRateBySource: [], topCompanies: [], timeInStage: [], salaryInsights: [] };
if (url.endsWith('/tag-trends')) return { months: [], series: [] };
return [];
}
function open() {
const router = createMemoryRouter([{ path: '/dashboard', element: <DashboardView /> }, { path: '/jobs', element: <div>Jobs destination</div> }], { initialEntries: ['/dashboard'] });
return render(<I18nProvider><ConfirmProvider><RouterProvider router={router} /></ConfirmProvider></I18nProvider>);
}
beforeEach(() => {
localStorage.clear(); stored = { version: 1, revision: 0, widgets: [] };
mocked.get.mockImplementation((url: string) => Promise.resolve({ data: response(url) }) as any);
mocked.put.mockReset(); mocked.put.mockImplementation((_url: string, body: any) => { stored = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: stored }) as any; });
});
async function edit() { fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' })); return screen.findByRole('region', { name: 'Editing dashboard' }); }
test('visibility is a preview; cancel restores the saved layout without a write', async () => {
open(); await screen.findByText('Tracked jobs');
const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: 'Cancel' }));
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Discard changes' }));
await screen.findByText('Tracked jobs'); expect(mocked.put).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByRole('button', { name: 'Customize dashboard' })).toHaveFocus());
});
test('failed save keeps the draft and retry persists visibility, order and size', async () => {
open(); const editor = await edit();
fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
fireEvent.click(within(editor).getByRole('button', { name: 'Move Today earlier' }));
fireEvent.change(within(editor).getByLabelText('Size for Today'), { target: { value: 'full' } });
mocked.put.mockRejectedValueOnce(new Error('offline'));
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await screen.findByText('Your layout could not be saved. Your changes are still here. Try again.');
expect(within(editor).getByRole('checkbox', { name: 'Summary cards' })).not.toBeChecked();
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
expect(stored.widgets[0]).toEqual({ id: 'attention', visible: true, size: 'full' });
expect(stored.widgets.find((widget: any) => widget.id === 'cards').visible).toBe(false);
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
});
test('all hidden widgets leave an accessible recovery and reset remains cancellable', async () => {
open(); const editor = await edit();
for (const checkbox of within(editor).getAllByRole('checkbox')) fireEvent.click(checkbox);
await screen.findByText('Your dashboard sections are hidden.');
fireEvent.click(within(editor).getByRole('button', { name: 'Reset to default' }));
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Reset to default' }));
await waitFor(() => expect(within(editor).getAllByRole('checkbox').every(item => (item as HTMLInputElement).checked)).toBe(true));
expect(mocked.put).not.toHaveBeenCalled();
});
test('keyboard ordering keeps focus and announces the resulting position without refetching reports', async () => {
open(); const editor = await edit(); const before = mocked.get.mock.calls.length;
const move = within(editor).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move);
await waitFor(() => expect(within(editor).getByRole('button', { name: 'Move Today later' })).toHaveFocus());
expect(screen.getByText('Today moved to position 1 of 9.')).toBeInTheDocument();
expect(mocked.get.mock.calls).toHaveLength(before);
});
test('dirty route navigation uses the existing confirmation and can stay on the dashboard', async () => {
open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Job tags' }));
fireEvent.click(screen.getByRole('link', { name: 'Tracked jobs: 2' }));
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Keep editing' }));
expect(await screen.findByRole('region', { name: 'Editing dashboard' })).toBeVisible();
expect(mocked.put).not.toHaveBeenCalled();
});
test('navigation waits for an in-flight save instead of promising to discard a possible commit', async () => {
open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Job tags' }));
let complete!: (value: any) => void;
mocked.put.mockImplementationOnce((_url: string, body: any) => new Promise(resolve => { complete = resolve; stored = { ...body, revision: 1 }; }) as any);
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
fireEvent.click(screen.getByRole('link', { name: 'Tracked jobs: 2' }));
expect(await screen.findByText('Please wait for your layout to finish saving before leaving.')).toBeVisible();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Jobs destination')).not.toBeInTheDocument();
await act(async () => { complete({ data: stored }); });
expect(await screen.findByRole('button', { name: 'Customize dashboard' })).toBeVisible();
});
@@ -5,6 +5,7 @@ import { MemoryRouter, useLocation } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
import { ConfirmProvider } from './confirm';
jest.mock('./components/OnboardingChecklist', () => ({ hasJobs }: { hasJobs: boolean | null }) => <><div>Setup retained</div><output aria-label="Setup job status">{hasJobs === null ? 'unknown' : String(hasJobs)}</output></>);
const mockedApi = api as jest.Mocked<typeof api>;
@@ -12,8 +13,10 @@ const stats = { total: 9, active: 8, deleted: 1, byStatus: { Applied: 3, Intervi
const overview = { funnel: [{ label: 'Applied', count: 3 }, { label: 'Interview', count: 2 }, { label: 'Offer', count: 1 }], totalActive: 8, totalResponses: 3, responseRateBySource: [{ label: 'NAV', total: 8, responses: 3, rate: 37.5 }], topCompanies: [{ companyId: 7, company: 'Nordlys AS', count: 8, responses: 3, responseRate: 37.5 }], timeInStage: [{ stage: 'Applied', medianDays: 4, count: 3 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
const monthly = [{ month: '2026-08', applied: 4, responses: 1 }, { month: '2026-09', applied: 2, responses: 1 }];
function Location() { return <output aria-label="Destination">{useLocation().pathname}{useLocation().search}</output>; }
function open() { return render(<I18nProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></I18nProvider>); }
function open() { return render(<I18nProvider><ConfirmProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></ConfirmProvider></I18nProvider>); }
let layout: any;
function data(url: string) {
if (url.endsWith('/preferences/dashboard')) return layout;
if (url.endsWith('/stats')) return stats;
if (url.endsWith('/analytics-overview')) return overview;
if (url.endsWith('/analytics')) return monthly;
@@ -21,7 +24,11 @@ function data(url: string) {
if (url.endsWith('/tag-trends')) return { months: ['2026-08', '2026-09'], series: [{ tag: 'TypeScript', counts: [3, 2] }] };
return [];
}
beforeEach(() => { localStorage.clear(); mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any); });
beforeEach(() => {
localStorage.clear(); layout = { version: 1, revision: 0, widgets: [] };
mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any);
mockedApi.put.mockImplementation((_url: string, body: any) => { layout = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: layout }) as any; });
});
test('[PRES-S01] exact monthly values, sample, custom statuses and separate salary group remain readable', async () => {
open();
@@ -37,11 +44,14 @@ test('[PRES-S01] exact monthly values, sample, custom statuses and separate sala
expect(screen.queryByText(/upcoming|conversion funnel|active applications/i)).not.toBeInTheDocument();
});
test('[PRES-S02] all five stored visibility flags hide their groups and survive remount', async () => {
test('[PRES-S02] all widget visibility choices save and survive remount', async () => {
let view = open(); await screen.findByText('Tracked jobs');
fireEvent.click(screen.getByRole('button', { name: 'Customize dashboard' }));
for (const item of screen.getAllByRole('menuitemcheckbox')) fireEvent.click(item);
expect(screen.getAllByRole('menuitemcheckbox').every(item => item.getAttribute('aria-checked') === 'false')).toBe(true);
const editor = await screen.findByRole('region', { name: 'Editing dashboard' });
for (const item of within(editor).getAllByRole('checkbox')) fireEvent.click(item);
expect(within(editor).getAllByRole('checkbox').every(item => !(item as HTMLInputElement).checked)).toBe(true);
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
view.unmount(); view = open();
await screen.findByText('Your dashboard sections are hidden.');
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
+64
View File
@@ -1743,6 +1743,38 @@ export const translations = {
dashboardCompanyJobsResponses: "{jobs} jobs · {responses} responses",
dashboardOverviewBody: "A quick view of your application activity, follow-ups, and momentum.",
dashboardCustomize: "Customize dashboard",
dashboardEditing: "Editing dashboard",
dashboardEditingBody: "Choose what matters to you. Changes appear below; save to use this layout on your other devices.",
dashboardManageWidgets: "Manage dashboard widgets",
dashboardSave: "Save layout",
dashboardSaving: "Saving layout…",
dashboardWaitForSave: "Please wait for your layout to finish saving before leaving.",
dashboardSaved: "Dashboard layout saved.",
dashboardDraftSaved: "Your saved layout. Changes are only applied when you save.",
dashboardSaveFailed: "Your layout could not be saved. Your changes are still here. Try again.",
dashboardLoadFailed: "Your saved layout could not be loaded. Retry before editing to protect your settings.",
dashboardUnsupportedVersion: "This layout was saved by a newer version. Reload or update the app before editing.",
dashboardConflict: "Your dashboard changed in another session. Reload the latest layout before applying your changes again.",
dashboardReload: "Reload latest layout",
dashboardReloadBody: "Replace your unsaved changes with the latest saved layout?",
dashboardReset: "Reset to default",
dashboardResetBody: "Restore the default dashboard widgets, order and sizes? This only changes your draft. Your jobs and other settings are kept.",
dashboardDiscardTitle: "Discard dashboard changes?",
dashboardDiscardBody: "Your layout has unsaved changes. Discard them and return to the saved layout?",
dashboardDiscard: "Discard changes",
dashboardEarlier: "Earlier",
dashboardLater: "Later",
dashboardMoveEarlierNamed: "Move {name} earlier",
dashboardMoveLaterNamed: "Move {name} later",
dashboardDrag: "Drag {name} to reorder",
dashboardMoved: "{name} moved to position {position} of {total}.",
dashboardSizeFor: "Size for {name}",
dashboardSize: "Size",
dashboardSizeStandard: "Standard",
dashboardSizeFull: "Full width",
dashboardReorderHelp: "Drag a handle to reorder, or use Earlier and Later with keyboard or touch. Hidden widgets keep their position and size. On small screens every widget fills the width.",
dashboardPreview: "Dashboard preview",
dashboardNoSample: "Not enough recorded data for this widget yet.",
dashboardSummaryCards: "Summary cards",
dashboardActivityChart: "Activity chart",
dashboardConversionFunnel: "Current pipeline",
@@ -4584,6 +4616,38 @@ export const translations = {
dashboardCompanyJobsResponses: "{jobs} jobber · {responses} svar",
dashboardOverviewBody: "En rask oversikt over aktivitet, oppfølginger og fremdrift i jobbsøket ditt.",
dashboardCustomize: "Tilpass dashboard",
dashboardEditing: "Tilpasser dashboard",
dashboardEditingBody: "Velg det som er viktig for deg. Endringene vises nedenfor. Lagre for å bruke oppsettet på de andre enhetene dine.",
dashboardManageWidgets: "Administrer dashboard-widgeter",
dashboardSave: "Lagre oppsett",
dashboardSaving: "Lagrer oppsett…",
dashboardWaitForSave: "Vent til oppsettet er ferdig lagret før du forlater siden.",
dashboardSaved: "Dashboard-oppsettet er lagret.",
dashboardDraftSaved: "Ditt lagrede oppsett. Endringene tas i bruk når du lagrer.",
dashboardSaveFailed: "Oppsettet kunne ikke lagres. Endringene dine er beholdt. Prøv igjen.",
dashboardLoadFailed: "Det lagrede oppsettet kunne ikke lastes. Prøv igjen før du redigerer, slik at innstillingene dine bevares.",
dashboardUnsupportedVersion: "Oppsettet ble lagret av en nyere versjon. Last inn på nytt eller oppdater appen før du redigerer.",
dashboardConflict: "Dashboardet ble endret i en annen økt. Last inn det nyeste oppsettet før du gjør endringene på nytt.",
dashboardReload: "Last inn nyeste oppsett",
dashboardReloadBody: "Erstatte endringene du ikke har lagret, med det nyeste lagrede oppsettet?",
dashboardReset: "Tilbakestill til standard",
dashboardResetBody: "Gjenopprette standard widgeter, rekkefølge og størrelser? Dette endrer bare utkastet. Jobbene og de andre innstillingene dine beholdes.",
dashboardDiscardTitle: "Forkaste dashboard-endringene?",
dashboardDiscardBody: "Oppsettet har endringer som ikke er lagret. Vil du forkaste dem og gå tilbake til det lagrede oppsettet?",
dashboardDiscard: "Forkast endringer",
dashboardEarlier: "Tidligere",
dashboardLater: "Senere",
dashboardMoveEarlierNamed: "Flytt {name} tidligere",
dashboardMoveLaterNamed: "Flytt {name} senere",
dashboardDrag: "Dra {name} for å endre rekkefølgen",
dashboardMoved: "{name} er flyttet til plass {position} av {total}.",
dashboardSizeFor: "Størrelse for {name}",
dashboardSize: "Størrelse",
dashboardSizeStandard: "Standard",
dashboardSizeFull: "Full bredde",
dashboardReorderHelp: "Dra i håndtaket for å endre rekkefølgen, eller bruk Tidligere og Senere med tastatur eller berøring. Skjulte widgeter beholder plass og størrelse. På små skjermer fyller alle widgeter bredden.",
dashboardPreview: "Forhåndsvisning av dashboard",
dashboardNoSample: "Det er ikke nok registrerte data for denne widgeten ennå.",
dashboardSummaryCards: "Oppsummeringskort",
dashboardActivityChart: "Aktivitetsgraf",
dashboardConversionFunnel: "Nåværende statusfordeling",
+3
View File
@@ -37,6 +37,9 @@ jest.mock('./components/MicrosoftAuthCard', () => () => null);
beforeEach(() => {
const { api } = require('./api');
api.get.mockImplementation((url: string) => {
if (url === '/auth/preferences/dashboard') {
return Promise.resolve({ data: { version: 1, revision: 0, widgets: [] } });
}
if (url === '/auth/config') {
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } });
}