feat(ui): refine global shell utilities
@@ -11,7 +11,8 @@ Reconciled against [P4-06](../phase-4/p4-06/README.md#opendeferred-issue-reconci
|
||||
| P4-04-O01 toast covers bottom action | Addressed in P5-00: bottom feedback clearance, native success toast tested while open | Feedback gate ready for the defined Connections action; not a universal overlay manager or company-modal error repair |
|
||||
| P5-00-D01 fixed worker data with system clock | **Closed 2026-09-21**: test now injects its existing fixed clock; complete backend rerun 916/916 | P5-01 baseline gate satisfied; production worker unchanged, no skip/expected annotation |
|
||||
| P5-00-D02 browser Back loses Jobs row focus | **Open** existing route behavior exposed by waiting for the actual workspace before Back; all four locale/theme cases. Explicit workspace Back passes `focusJobId` and remains protected | **Blocks affected P5-03/08 navigation accessibility acceptance**, not unchanged shell. Browser query and local-density reset plus explicit-button focus have separate oracles; no route/table application fix in P5-00 |
|
||||
| P3-O02 command error→empty / late query results | Source-confirmed search catches; explicit failure/pending isolation needs owner tests | **Blocks later slice P5-02 acceptance**; retain existing simple commands in 01, no expanded command redesign in 00 |
|
||||
| P3-O02 command error→empty / late query results | **Addressed in P5-02**: failed search has Retry, aborted/late results are discarded after a newer query or close; focused and real-browser regressions pass | Search-state gate closed locally; manual screen-reader acceptance is separately open below |
|
||||
| P5-02-D01 utility screen-reader operation | Automated axe and keyboard checks pass, but no human screen-reader command/notification task has been recorded | **Blocks P5-02 accessibility acceptance and automatic P5-03 progression** under the approved P5-02 plan. Operator steps and evidence request are in [P5-02](p5-02/README.md) |
|
||||
| P3-O02 workspace search replacement loses `followMode` | Source-confirmed `setParams({section})` | **Blocks later slice P5-08b** adapter migration until retained-query proof |
|
||||
| P4-02-D03 mobile progress scroll not focusable | Open loaded axe finding | **Blocks P5-08a affected Overview migration**, not shell; targeted G-WORKSPACE proof |
|
||||
| P4-02-D04 literal AI context `{name}` | Open both locales | **Blocks P5-08b affected application AI labels**; correct interpolation without changing context/API |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 5 — Representative screens / major UI redesign plan
|
||||
|
||||
**Completed successor: [P5-01 AppShell and grouped navigation](p5-01/README.md).** The first major visible redesign groups every existing destination without changing the 31-pattern route/provider contract. Its inherited backend prerequisite repair established **918/918**, and the P5-01 shell route/focus/accessibility contract is protected. P5-02 and B07 have not started. F04 remains open.
|
||||
**Current successor: [P5-02 Global shell utilities](p5-02/README.md), implementation verified with conditional accessibility acceptance.** The first major visible redesign, P5-01, grouped existing destinations without changing the 31-pattern route/provider contract. P5-02 retains existing commands and notifications while fixing search error/stale-result semantics. A manual screen-reader utility task is still required before automatic P5-03 progression. B07 has not started; F04 remains open.
|
||||
|
||||
Historical planning checkpoint: source `2a2f9e48052c562ef599825458d0d3a6f9f9fc61`, inspected on 2026-09-13 on `codex/ui-redesign-phase4`. That checkpoint was documentation-only; the successor record above owns implementation/validation status.
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# P5-02 — Global shell utilities
|
||||
|
||||
Source: [approved Phase 5 plan](../PHASE5_PLAN.md#p5-02--global-shell-utilities). This checkpoint follows P5-01 `8e56cea`; no route, provider, storage, backend contract, notification producer or command catalogue was added. F04 and P2-D01 remain open. P5-03 and B07 have not started.
|
||||
|
||||
## Result and preservation
|
||||
|
||||
The six existing commands and existing job/company results now appear in labelled Actions and pages, Jobs and Companies groups. Search retains the owned API client and 250 ms debounce. One aborted request pair per query prevents old results from replacing newer results or appearing after the dialog closes/account changes. A failed request shows a Retry alert; it is never represented as an empty result. The existing no-match state appears only after successful search. Arrow Up/Down, Home/End and Enter work from the search field and result buttons; IME composition is ignored. Ordinary Tab, Escape, initial focus and MUI focus return remain intact. The result announcer changes after the debounced search settles rather than on every keystroke.
|
||||
|
||||
The existing bell retains the same recent-notification endpoint, read/dismiss mutations and `/operations` link. Loads are cancelled when closed or replaced, private items clear on close, and late mutation completions cannot update the next panel generation. Unread items now have a text label independent of their selected surface colour. A load error keeps its Retry action and does not show an empty inbox. Opening a notification still marks it read where needed; it does not complete a task.
|
||||
|
||||
| PRES contract | Existing behavior retained | P5-02 change | Evidence |
|
||||
|---|---|---|---|
|
||||
| PRES-E07 | Six actions/pages, exact job/company destinations, keyboard trigger, reviewed Add Job | Group and keyboard navigation; distinguish loading/error/empty | `quick-command-preservation.test.tsx`; real-App four-locale/theme utility journeys and existing command/dirty-navigation browser cases |
|
||||
| PRES-E08 | EN/Bokmål, light/dark, dialog/feedback and focus | Localized groups/status and inherited semantic focus/colour | Type/lint/browser scans; four-mode screenshots and keyboard review |
|
||||
| PRES-E09 | Authenticated session isolation and private-tree disposal | Abort/ignore stale command and bell responses | Deferred-response tests; existing B02/session suite and browser account-switch coverage |
|
||||
| PRES-J03/J08 | Add opens review without POST; existing job/company/notification navigation | Presentation only | Existing no-write command test, exact destination browser test, job/operation route tests |
|
||||
| PRES-S02 | Owner-local notifications, read/dismiss/operation link | Explicit unread label and stale-load protection | `notifications-popover.test.tsx` read/dismiss/link/empty/error/stale cases |
|
||||
| PRES-W11/W12 | Existing operation links and task state | Bell still routes to `/operations`; no task completion side effect | Existing notification/operation tests; synthetic browser read/dismiss probe |
|
||||
|
||||
The route/provider tree, API authentication, notification store, command actions, shell header triggers and domain workflows are unchanged. Existing rendering is retained and consolidated; there is no new command platform, Documents/CV search, task producer, email delivery or global AI assistant.
|
||||
|
||||
## Verification and visual evidence
|
||||
|
||||
The inherited P5-01 baseline is backend **918**, frontend **365**, browser **70 actual passes + unchanged P2-D01 expected failure**, rate-limit **9**, ingress **6**, PDF **1**, configuration **3**. Final P5-02 counts and commands are recorded below after the final code change.
|
||||
|
||||
| Check | Final result |
|
||||
|---|---|
|
||||
| Backend Release tests | **918/918 passed**, zero skipped; unchanged from P5-01 |
|
||||
| Frontend Jest | **373/373 passed across 77 suites**; +8 focused cases (search failure/retry, close/account and query races, input/result keyboard operation, stale notification load/read navigation, notification failure/retry) |
|
||||
| Complete Chromium browser | **74 actual passes + unchanged P2-D01 expected failure**, zero unexpected/flaky cases; +4 four-mode utility journeys from P5-01. `PLAYWRIGHT_STATIC_EXPORT=true PLAYWRIGHT_PREBUILT_EXPORT=true npm.cmd run test:e2e -- --reporter=list,json`; accepted result `.artifacts/p5-02-browser-accepted.json` |
|
||||
| TypeScript / edited E2E TypeScript / lint | Passed (`npm.cmd run typecheck`; E2E `tsc --project ../.artifacts/p5-00-e2e-tsconfig.json --noEmit`; `npm.cmd run lint`) |
|
||||
| Frontend production build / API Release build | Passed; API Release build `-warnaserror` has zero warnings/errors |
|
||||
| Rate-limit / ingress / PDF / configuration | **9 / 6 / 1 / 3 passed** using their existing local Playwright configurations and `python scripts/test-ingress.py` |
|
||||
| Secret / whitespace / documentation links | Tracked-secret scan and working/staged `git diff --check` passed; current/root Markdown links **134 documents, zero missing local targets** (historical archive excluded) |
|
||||
|
||||
Supply-chain, model-evaluation harness and browser-extension local checks passed **3 / 5 / 3**. These do not certify live external providers. Accepted backend TRX and frontend JSON reports are in ignored `.artifacts/redesign-p5-02/` and `.artifacts/p5-02-jest-accepted.json`.
|
||||
|
||||
The final production-browser run and a focused repeat passed four utility journeys: light/English and dark/English at 1440px; light/Bokmål and dark/Bokmål at 390px. They exercised search outage, keyboard command activation, unread/read/dismiss and captured both utility overlays. Scoped axe scans of the command error and notification panel had zero violations; their incomplete nodes are not counted as passes. Eight final-build captures are under `visual/`. Direct review confirmed the light desktop error alert remains readable and the dark Bokmål phone notification panel fits below the app bar without covering its own actions. Manual screen-reader operation, zoom/forced-colour behavior and provider outage timing remain explicit validation gaps.
|
||||
|
||||
**Acceptance status: conditional.** The [approved plan](../PHASE5_PLAN.md#p5-02--global-shell-utilities) requires a manual screen-reader command and notification task, or explicit blocking of accessibility acceptance pending operator proof. Automated axe and keyboard tests do not substitute for that task. A Windows operator should use Narrator/Edge or NVDA/Chrome on the committed build: open search with Ctrl+K, hear the dialog/search name and settled count, move between grouped actions with arrow/Home/End and Tab, activate a job/company result, close with Escape and confirm focus return; open the bell, hear loading/error/unread state, then read, dismiss and open an operation link. Repeat representative English/Bokmål and light/dark states. Record the spoken names/order, any silent state transition, and screenshot or short recording with credentials hidden. Until this passes, P5-02 is committed but **P5-03 progression is blocked** under the sequential gate.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
- P5-00-D02 Jobs browser Back focus and P4-05-O01 localized table display still gate P5-03 acceptance.
|
||||
- P4-04-O02 SQLite concurrent-write contention remains a separate backend reliability defect. P5-02 adds no polling or writes; browser probes use local synthetic accounts and no external provider.
|
||||
- F04 requires production ingress evidence. P2-D01 requires native package proof. Neither is closed by this UI checkpoint.
|
||||
- P5-05 quick preview stays deferred; P5-04b Table/Board parity, P5-08b native package proof and P5-09–14 CV revision/rendering proof retain their separate gates.
|
||||
|
||||
Rollback is limited to the two utility components, their localized labels, and tests; all existing routes and data remain valid.
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -5,6 +5,7 @@ Status reconciled: 2026-09-21.
|
||||
## Current repository work
|
||||
|
||||
- **P5-01 — AppShell/grouped navigation complete:** existing destinations are grouped under Dashboard, Job search, Career, Admin and Account; expanded/rail/mobile access, parent/child current semantics, skip/main and route-heading focus have automated and visual evidence. Final baseline: 918 backend, 365 frontend, 70 browser passes plus unchanged P2-D01 expected failure. [Record](../redesign/phase-5/p5-01/README.md). P5-02 remains separate; no route/provider/business change or B07 work.
|
||||
- **P5-02 — global shell utilities implemented, accessibility acceptance conditional:** command error/retry and stale-result isolation, grouped existing commands, keyboard selection, and notification stale-load/read/dismiss presentation pass 918 backend, 373 frontend and 74 actual browser tests plus unchanged P2-D01 expected failure. [Record](../redesign/phase-5/p5-02/README.md). P5-02-D01 requires a manual screen-reader utility task before P5-03 progression; F04 remains open.
|
||||
- **P5-00 — complete, P5-01 GO:** [record](../redesign/phase-5/p5-00/README.md) covers the five bounded shell gates, shared 1024px navigation boundary and open-drawer resize lifecycle. P5-00-D01 is closed with the existing fixed clock injected into the test; backend rerun is 916/916. No grouped-navigation redesign or B07 work in that checkpoint. F04 remains open.
|
||||
- **P5-00-D02 — open Jobs browser-Back focus debt:** after the workspace actually renders, browser Back preserves the query but does not restore row focus. The explicit workspace Back button carries the existing focus contract. Corrected browser evidence distinguishes them; no application behavior changed. Resolve before affected P5-03/08 navigation migration. [Evidence](../redesign/phase-5/p5-00/README.md#inherited-density-navigation-oracle-corrected).
|
||||
- **Phase 5 plan:** [bounded plan](../redesign/phase-5/README.md) maps all 57 contracts/31 routes. Board integration, native package and CV revision/rendering gates apply only to dependent slices. F04 remains open.
|
||||
|
||||
@@ -228,6 +228,55 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
|
||||
expect((await (await page.request.get(`${apiUrl}/jobapplications`)).json()).total).toBe(1);
|
||||
});
|
||||
|
||||
test(`[P5-02 PRES-E07 E08 E09 S02] ${suffix} utility search and notification states`, async ({ page }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const t = translations[language];
|
||||
await page.setViewportSize({ width: language === "nb" ? 390 : 1440, height: language === "nb" ? 844 : 900 });
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByRole("heading", { level: 1, name: t.settings })).toBeVisible();
|
||||
|
||||
await page.route("**/api/jobapplications?**", async route => {
|
||||
if (new URL(route.request().url()).searchParams.get("q") === "outage") {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: '{"message":"Search is temporarily unavailable"}' });
|
||||
} else await route.continue();
|
||||
});
|
||||
const trigger = page.getByRole("button", { name: new RegExp(t.quickSearch) });
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
const dialog = page.getByRole("dialog", { name: t.quickSearch });
|
||||
const input = dialog.getByRole("textbox", { name: t.quickSearch });
|
||||
await input.fill("outage");
|
||||
await expect(dialog.getByRole("alert")).toBeVisible();
|
||||
await expect(dialog.getByText(t.noMatchingCommands)).toHaveCount(0);
|
||||
await capture(page, info, `utility-error-${suffix}`);
|
||||
await scan(page, info, `utility-error-${suffix}`, '[role="dialog"]');
|
||||
await input.fill("");
|
||||
await input.press("End");
|
||||
await input.press("Home");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/jobs$/);
|
||||
|
||||
const notification = {
|
||||
id: "22222222-2222-2222-2222-222222222222", operationId: null, kind: "operation_failed",
|
||||
title: "CV export failed", message: "Review the operation and try again.", linkPath: "/operations",
|
||||
createdAtUtc: "2026-08-15T12:00:00Z", readAtUtc: null,
|
||||
};
|
||||
await page.route("**/api/notifications?limit=10", route => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([notification]) }));
|
||||
await page.route("**/api/notifications/*/read", route => route.fulfill({ status: 200, body: "{}" }));
|
||||
await page.route("**/api/notifications/*", route => route.request().method() === "DELETE"
|
||||
? route.fulfill({ status: 204, body: "" }) : route.continue());
|
||||
await page.getByRole("button", { name: t.notifications, exact: true }).click();
|
||||
await expect(page.getByText("CV export failed")).toBeVisible();
|
||||
await expect(page.getByText(t.notificationsUnread)).toBeVisible();
|
||||
await capture(page, info, `utility-notifications-${suffix}`);
|
||||
await scan(page, info, `utility-notifications-${suffix}`, ".MuiPopover-paper");
|
||||
await page.getByRole("button", { name: t.notificationsMarkRead }).click();
|
||||
await expect(page.getByRole("button", { name: t.notificationsMarkRead })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: t.notificationsDismiss }).click();
|
||||
await expect(page.getByText("CV export failed")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`[P5-00 PRES-E08 C06 S05] ${suffix} real Tooltip contrast and open-toast action access`, async ({ page, owner }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const t = translations[language];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -34,59 +34,79 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const activeLoad = useRef<AbortController | null>(null);
|
||||
const openGeneration = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
activeLoad.current?.abort();
|
||||
const controller = new AbortController();
|
||||
activeLoad.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get<UserNotification[]>("/notifications?limit=10");
|
||||
const response = await api.get<UserNotification[]>("/notifications?limit=10", { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setNotifications(response.data ?? []);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
|
||||
if (!controller.signal.aborted) setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (activeLoad.current === controller) setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
openGeneration.current += 1;
|
||||
if (open) void load();
|
||||
else {
|
||||
activeLoad.current?.abort();
|
||||
setNotifications([]);
|
||||
setError(null);
|
||||
setBusyId(null);
|
||||
}
|
||||
return () => activeLoad.current?.abort();
|
||||
}, [load, open]);
|
||||
|
||||
const markRead = async (notification: UserNotification) => {
|
||||
if (notification.readAtUtc || busyId) return;
|
||||
const generation = openGeneration.current;
|
||||
setBusyId(notification.id);
|
||||
try {
|
||||
await api.post(`/notifications/${notification.id}/read`);
|
||||
if (generation !== openGeneration.current) return;
|
||||
setNotifications((items) => items.map((item) => item.id === notification.id
|
||||
? { ...item, readAtUtc: new Date().toISOString() }
|
||||
: item));
|
||||
setError(null);
|
||||
onChanged();
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
if (generation === openGeneration.current) setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = async (notification: UserNotification) => {
|
||||
if (busyId) return;
|
||||
const generation = openGeneration.current;
|
||||
setBusyId(notification.id);
|
||||
try {
|
||||
await api.delete(`/notifications/${notification.id}`);
|
||||
if (generation !== openGeneration.current) return;
|
||||
setNotifications((items) => items.filter((item) => item.id !== notification.id));
|
||||
setError(null);
|
||||
onChanged();
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
if (generation === openGeneration.current) setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openNotification = async (notification: UserNotification) => {
|
||||
if (!notification.linkPath) return;
|
||||
const generation = openGeneration.current;
|
||||
if (!notification.readAtUtc) await markRead(notification);
|
||||
if (generation !== openGeneration.current) return;
|
||||
onClose();
|
||||
onNavigate(notification.linkPath);
|
||||
};
|
||||
@@ -113,7 +133,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{t("notifications")}</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 700 }}>{t("notifications")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("notificationsRecent")}</Typography>
|
||||
</Box>
|
||||
<Tooltip title={t("close")}>
|
||||
@@ -123,7 +143,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ overflowY: "auto", maxHeight: "min(52vh, 450px)", p: 1 }}>
|
||||
{loading ? <Box sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
|
||||
{loading ? <Box role="status" sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
|
||||
{error ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void load()}>{t("retry")}</Button>} sx={{ m: 0.5 }}>{error}</Alert> : null}
|
||||
{!loading && !error && notifications.length === 0 ? (
|
||||
<Box sx={{ py: 4, px: 2, textAlign: "center" }}>
|
||||
@@ -131,7 +151,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
<Typography variant="body2" color="text.secondary">{t("notificationsEmptyBody")}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{!loading ? notifications.map((notification) => (
|
||||
{!loading && !error ? notifications.map((notification) => (
|
||||
<Box
|
||||
key={notification.id}
|
||||
sx={{
|
||||
@@ -144,7 +164,8 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
opacity: notification.readAtUtc ? 0.82 : 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 700 : 900, overflowWrap: "anywhere" }}>{notification.title}</Typography>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 700, overflowWrap: "anywhere" }}>{notification.title}</Typography>
|
||||
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25, overflowWrap: "anywhere" }}>{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc)}</Typography>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap" }}>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
List,
|
||||
@@ -47,13 +49,19 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
const [query, setQuery] = useState("");
|
||||
const [jobs, setJobs] = useState<JobSearchItem[]>([]);
|
||||
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
|
||||
const [searchState, setSearchState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
const [searchAttempt, setSearchAttempt] = useState(0);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const resultRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setJobs([]);
|
||||
setCompanies([]);
|
||||
setSearchState("idle");
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
@@ -64,25 +72,28 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
if (!q) {
|
||||
setJobs([]);
|
||||
setCompanies([]);
|
||||
setSearchState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setJobs([]);
|
||||
setCompanies([]);
|
||||
setSearchState("loading");
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 } })
|
||||
.then((r) => setJobs(r.data?.items ?? []))
|
||||
.catch(() => setJobs([]));
|
||||
api
|
||||
.get<CompanySearchItem[]>("/companies")
|
||||
.then((r) => {
|
||||
const matches = (r.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
|
||||
setCompanies(matches);
|
||||
Promise.all([
|
||||
api.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 }, signal: controller.signal }),
|
||||
api.get<CompanySearchItem[]>("/companies", { signal: controller.signal }),
|
||||
])
|
||||
.then(([jobResponse, companyResponse]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setJobs(jobResponse.data?.items ?? []);
|
||||
setCompanies((companyResponse.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8));
|
||||
setSearchState("ready");
|
||||
})
|
||||
.catch(() => setCompanies([]));
|
||||
.catch(() => { if (!controller.signal.aborted) setSearchState("error"); });
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [open, query]);
|
||||
return () => { window.clearTimeout(timeout); controller.abort(); };
|
||||
}, [open, query, searchAttempt]);
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const base: CommandItem[] = [
|
||||
@@ -99,25 +110,44 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q));
|
||||
}, [onNavigate, onOpenAddJob, query, t]);
|
||||
|
||||
const allItems: CommandItem[] = [
|
||||
...commands,
|
||||
...jobs.slice(0, 6).map((job) => ({
|
||||
const jobItems: CommandItem[] = jobs.slice(0, 6).map((job) => ({
|
||||
id: `job-${job.id}`,
|
||||
label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`,
|
||||
hint: t("openJobListAndSearchResult"),
|
||||
action: () => onNavigate(`/jobs/${job.id}`),
|
||||
})),
|
||||
...companies.slice(0, 6).map((company) => ({
|
||||
}));
|
||||
const companyItems: CommandItem[] = companies.slice(0, 6).map((company) => ({
|
||||
id: `company-${company.id}`,
|
||||
label: company.name,
|
||||
hint: t("openCompanies"),
|
||||
action: () => onNavigate(`/companies?edit=${company.id}`),
|
||||
})),
|
||||
}));
|
||||
const groups = [
|
||||
{ label: t("commandActions"), items: commands },
|
||||
{ label: t("commandJobs"), items: jobItems },
|
||||
{ label: t("commandCompanies"), items: companyItems },
|
||||
];
|
||||
const allItems = groups.flatMap((group) => group.items);
|
||||
const activate = (item: CommandItem) => { item.action(); onClose(); };
|
||||
const onKeys = (event: React.KeyboardEvent) => {
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229 || !allItems.length) return;
|
||||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const current = event.target === searchInputRef.current ? activeIndex : resultRefs.current.indexOf(event.target as HTMLElement);
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? allItems.length - 1
|
||||
: event.key === "ArrowDown" ? (current + 1) % allItems.length
|
||||
: (current < 0 ? allItems.length - 1 : (current - 1 + allItems.length) % allItems.length);
|
||||
setActiveIndex(next);
|
||||
if (event.target !== searchInputRef.current) resultRefs.current[next]?.focus();
|
||||
} else if (event.key === "Enter" && event.target === searchInputRef.current) {
|
||||
event.preventDefault();
|
||||
activate(allItems[activeIndex < 0 ? 0 : activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
|
||||
<DialogContent sx={{ p: 1.5 }}>
|
||||
<DialogContent onKeyDown={onKeys} sx={{ p: 1.5, maxHeight: "min(70vh, 620px)" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
|
||||
<SearchIcon fontSize="small" />
|
||||
<TextField
|
||||
@@ -128,7 +158,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
placeholder={t("searchPlaceholder")}
|
||||
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); }}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", whiteSpace: "nowrap" }}>
|
||||
@@ -136,27 +166,22 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<List sx={{ mt: 1 }}>
|
||||
{allItems.length === 0 ? (
|
||||
<ListItem sx={{ px: 2, py: 3 }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
|
||||
</ListItem>
|
||||
) : (
|
||||
allItems.map((item) => (
|
||||
<ListItem key={item.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
<Box aria-live="polite" sx={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
|
||||
{searchState === "loading" ? t("loading") : searchState === "ready" ? t("searchResultsCount", { count: allItems.length }) : ""}
|
||||
{activeIndex >= 0 ? ` ${allItems[activeIndex]?.label ?? ""}` : ""}
|
||||
</Box>
|
||||
{searchState === "error" ? <Alert severity="error" sx={{ mt: 1 }} action={<Button color="inherit" size="small" onClick={() => setSearchAttempt((value) => value + 1)}>{t("retry")}</Button>}>{t("searchLoadFailed")}</Alert> : null}
|
||||
{groups.map((group) => group.items.length ? <Box component="section" key={group.label} sx={{ mt: 1 }}>
|
||||
<Typography component="h2" variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 700 }}>{group.label}</Typography>
|
||||
<List dense aria-label={group.label}>
|
||||
{group.items.map((item) => <ListItem key={item.id} disablePadding>
|
||||
<ListItemButton ref={(node) => { resultRefs.current[allItems.indexOf(item)] = node; }} selected={activeIndex === allItems.indexOf(item)} onFocus={() => setActiveIndex(allItems.indexOf(item))} onClick={() => activate(item)} sx={{ borderRadius: 2 }}>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
</ListItem>)}
|
||||
</List>
|
||||
</Box> : null)}
|
||||
{allItems.length === 0 && searchState !== "loading" && searchState !== "error" ? <Typography color="text.secondary" sx={{ px: 2, py: 3 }}>{t("noMatchingCommands")}</Typography> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -144,6 +144,7 @@ export const translations = {
|
||||
notifications: "Notifications",
|
||||
notificationsPanel: "Notifications panel",
|
||||
notificationsRecent: "Recent updates and background work",
|
||||
notificationsUnread: "Unread",
|
||||
notificationsLoadFailed: "Notifications could not be loaded.",
|
||||
notificationsActionFailed: "The notification could not be updated.",
|
||||
notificationsEmptyTitle: "You're all caught up",
|
||||
@@ -156,6 +157,11 @@ export const translations = {
|
||||
quickSearch: "Quick Search",
|
||||
searchPlaceholder: "Search jobs, companies, or actions",
|
||||
noMatchingCommands: "No matching commands or records.",
|
||||
commandActions: "Actions and pages",
|
||||
commandJobs: "Jobs",
|
||||
commandCompanies: "Companies",
|
||||
searchLoadFailed: "Search could not be loaded.",
|
||||
searchResultsCount: "{count} results available.",
|
||||
goToDashboard: "Go to dashboard",
|
||||
analyticsOverview: "Analytics overview",
|
||||
goToJobs: "Go to jobs",
|
||||
@@ -2600,6 +2606,7 @@ export const translations = {
|
||||
notifications: "Varsler",
|
||||
notificationsPanel: "Varslingspanel",
|
||||
notificationsRecent: "Nylige oppdateringer og bakgrunnsarbeid",
|
||||
notificationsUnread: "Ulest",
|
||||
notificationsLoadFailed: "Varslene kunne ikke lastes.",
|
||||
notificationsActionFailed: "Varslet kunne ikke oppdateres.",
|
||||
notificationsEmptyTitle: "Du er ajour",
|
||||
@@ -2612,6 +2619,11 @@ export const translations = {
|
||||
quickSearch: "Hurtigsøk",
|
||||
searchPlaceholder: "Søk etter jobber, selskaper eller handlinger",
|
||||
noMatchingCommands: "Ingen treff på kommandoer eller poster.",
|
||||
commandActions: "Handlinger og sider",
|
||||
commandJobs: "Jobber",
|
||||
commandCompanies: "Selskaper",
|
||||
searchLoadFailed: "Søket kunne ikke lastes.",
|
||||
searchResultsCount: "{count} treff tilgjengelig.",
|
||||
goToDashboard: "Gå til dashboard",
|
||||
analyticsOverview: "Analyseoversikt",
|
||||
goToJobs: "Gå til jobber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import NotificationsPopover from "./components/NotificationsPopover";
|
||||
@@ -53,7 +53,7 @@ test("shows notifications and updates read and dismissed state in place", async
|
||||
const { props } = renderPopover();
|
||||
|
||||
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=10");
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mark read" }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${notification.id}/read`));
|
||||
@@ -82,3 +82,36 @@ test("shows an honest empty state", async () => {
|
||||
|
||||
expect(await screen.findByText("You're all caught up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E09] a closed notification request cannot populate a later account's panel", async () => {
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
mockedApi.get.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; }) as never);
|
||||
const { rerender, props, anchor } = renderPopover();
|
||||
await waitFor(() => expect(resolveOld).toBeDefined());
|
||||
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={anchor} /></I18nProvider>);
|
||||
await act(async () => resolveOld({ data: [notification] }));
|
||||
expect(await screen.findByText("You're all caught up")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CV export failed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E09] a delayed read cannot navigate from a closed earlier-account panel", async () => {
|
||||
let resolveRead!: (value: unknown) => void;
|
||||
mockedApi.post.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; }) as never);
|
||||
const { rerender, props } = renderPopover();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(resolveRead).toBeDefined());
|
||||
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
|
||||
await act(async () => resolveRead({ data: {} }));
|
||||
expect(props.onNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-S02] failed notification load offers Retry without claiming an empty inbox", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
|
||||
renderPopover();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Notifications could not be loaded");
|
||||
expect(screen.queryByText("You're all caught up")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import QuickCommandDialog from "./components/QuickCommandDialog";
|
||||
@@ -48,12 +48,80 @@ test("[PRES-E07 PRES-J04] the capture command opens review without creating a jo
|
||||
|
||||
test("[P5-00 PRES-E07] command and empty results use valid list semantics", async () => {
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const list = screen.getByRole("list");
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(6);
|
||||
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
|
||||
expect(screen.getAllByRole("list").every(list => Array.from(list.children).every(item => item.tagName === "LI"))).toBe(true);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-such-result" } });
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as never);
|
||||
await screen.findByText("No matching commands or records.");
|
||||
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
|
||||
expect(screen.queryAllByRole("list").every(list => Array.from(list.children).every(item => item.tagName === "LI"))).toBe(true);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] search failure is distinct from no matches and Retry recovers", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Quick Search" }), { target: { value: "Nordlys" } });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Search could not be loaded");
|
||||
expect(screen.queryByText("No matching commands or records.")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("Nordlys AS - Platform engineer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E09] closed and superseded searches cannot display stale account results", async () => {
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/jobapplications"
|
||||
? new Promise(resolve => { resolveOld = resolve; }) as never
|
||||
: Promise.resolve({ data: [] }) as never);
|
||||
const props = { onClose: jest.fn(), onNavigate: jest.fn(), onOpenAddJob: jest.fn() };
|
||||
const view = (open: boolean) => <I18nProvider><QuickCommandDialog open={open} {...props} /></I18nProvider>;
|
||||
const { rerender } = render(view(true));
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "old" } });
|
||||
await waitFor(() => expect(resolveOld).toBeDefined());
|
||||
rerender(view(false));
|
||||
rerender(view(true));
|
||||
await act(async () => resolveOld({ data: { items: [{ id: 42, jobTitle: "Account A job", company: { name: "Private" } }] } }));
|
||||
expect(screen.queryByText(/Account A job/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] a slower old query cannot replace the newer matching results", async () => {
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === "/companies"
|
||||
? Promise.resolve({ data: [] }) as never
|
||||
: config?.params?.q === "old"
|
||||
? new Promise(resolve => { resolveOld = resolve; }) as never
|
||||
: Promise.resolve({ data: { items: [{ id: 9, jobTitle: "New result", company: { name: "Current" } }] } }) as never);
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const search = screen.getByRole("textbox", { name: "Quick Search" });
|
||||
fireEvent.change(search, { target: { value: "old" } });
|
||||
await waitFor(() => expect(resolveOld).toBeDefined());
|
||||
fireEvent.change(search, { target: { value: "new" } });
|
||||
expect(await screen.findByText("Current - New result")).toBeInTheDocument();
|
||||
await act(async () => resolveOld({ data: { items: [{ id: 8, jobTitle: "Old result", company: { name: "Private" } }] } }));
|
||||
expect(screen.queryByText("Private - Old result")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] arrows, Home, End and Enter activate one existing command; composition does not", () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const search = screen.getByRole("textbox", { name: "Quick Search" });
|
||||
fireEvent.keyDown(search, { key: "End" });
|
||||
fireEvent.keyDown(search, { key: "Enter", isComposing: true });
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(search, { key: "Home" });
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onNavigate).toHaveBeenCalledWith("/jobs");
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] arrowing from a focused result keeps focus and activation on the same item", () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const dashboard = screen.getByRole("button", { name: /go to dashboard/i });
|
||||
act(() => dashboard.focus());
|
||||
fireEvent.keyDown(dashboard, { key: "ArrowDown" });
|
||||
const jobs = screen.getByRole("button", { name: /go to jobs/i });
|
||||
expect(jobs).toHaveFocus();
|
||||
fireEvent.click(jobs);
|
||||
expect(onNavigate).toHaveBeenCalledWith("/jobs");
|
||||
});
|
||||
|
||||