feat(ui): migrate discovery search and recovery
This commit is contained in:
@@ -39,6 +39,10 @@ Exact direct receipt is retained at `.artifacts/phase7/p7-03/import-history/fina
|
||||
| P7-03 observed push receipt / P7-04 entry | `5680c0031f377b0caaae79c0cec900f58e409886` | Records the actual preceding 5d6e3235 clean normal push, closes implemented P7-03 conditionally and establishes exact library/public entry baselines. Entry static PASS (four commands0,11docs/400 local links0errors/57groups/32routes); normal push directly verified2026-10-12 17:35:00.882169UTC: localHEAD=origin=live5680c00, clean worktree, local/origin/live main27657f3 unchanged. No product/runtime/count change |
|
||||
| P7-04 CV library | `fcacae7384b4a38c2675e6527a33cc1d09f4b505` | [Library report](p7-04/README.md#cv-library-implemented-result), [finite results](p7-04/evidence/library-results.json): affected38/4/native8/editor4/backend72/types/lint/build PASS;19 actually reviewed PNGs;19 all-enabled scans0/64 plus12 retained editor scans0/122 with manual conditions. Current10 sources/75-file export verified against exact frozen final; +12/0 gives660/93 and +8 gives241 derived only. Final staged hygiene PASS18:03:12UTC (four commands0,11docs406links0errors/57+32); normal push directly verified18:03:41.481787UTC localHEAD=origin=livefcacae7, clean worktree/main27657f3 unchanged. Actual receipt recorded by public successor. Public entry remains pending; no main/renderer/backend/AI activation |
|
||||
|
||||
| P7-04 public CV entry | Actual hash follows observed normal push | [Public results](p7-04/evidence/public-results.json): fresh38/3/native8/types/lint/build/fidelity16/36+8/24 PASS;18 actual image reviews;18 all-enabled scans0/12 sandbox-frame incompletes and12 separate settings scans0/223 retained. Current7 source snapshots/74-file export verified. +13/0 gives673/93 and native+8 gives249 derived only. Final staged hygiene/normal push pending; no main/schema/renderer/model activation |
|
||||
| P7-04 public CV entry | `bfa0ab62f8399398191b020c4eae78fac0bafc9a` | [Public results](p7-04/evidence/public-results.json): fresh38/3/native8/types/lint/build/fidelity16/36+8/24 PASS;18 actual image reviews;18 all-enabled scans0/12 sandbox-frame incompletes and12 separate settings scans0/223 retained. Current7 source snapshots/74-file export verified. +13/0 gives673/93 and native+8 gives249 derived only. Final staged hygiene/normal push pending; no main/schema/renderer/model activation |
|
||||
|
||||
Peer review found no product defect. The native geometry test now additionally asserts rendered width equals min(A4, viewport minus32px) within1px; focused existing eight-sample journey passes after that assertion, with fresh browser types. Product source/build/fidelity remain unchanged. Seven new screenshots exactly match previously reviewed SHA bytes; the differing1920 capture was displayed/reviewed. Extra eight scans0/8 frame-tested are repeated evidence, not additional cases. The first targeted runner grep matched0 tests/exit1; raw failure retained and exact-title grep corrected, no skip or expected failure.
|
||||
|
||||
Observed normal public push2026-10-12 22:24:44.035140UTC proves localHEAD=origin=live bfa0ab6, clean worktree and unchanged local/origin/live main27657f3. P7-04 implemented CONDITIONAL GO; source/output/units/native/fidelity/static proof and all iframe/manual/release conditions remain. P7-05 successor records exact receipt and13 committed source baselines; no product evidence replaced.
|
||||
|
||||
| P7-05 Discovery | Actual hash follows observed normal push | [Results](p7-05/evidence/discovery-results.json):21/1,new8+existing4native,backend14,app/browser types/lint/build PASS;18/31images actually reviewed;new18scans0/81+existing9scans0/52 retained. Final hygiene/normal push pending. +9/0 frontend682/93 and browser257 derived only; other P7-05 domains pending; main unchanged |
|
||||
|
||||
@@ -134,3 +134,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### P7-04 public evidence
|
||||
|
||||
Public18 top-document all-enabled scans0/12 retain sandbox frame-tested occurrences: generated document context is not fully scanned or human accepted. Keep scripts withheld; M09/M11 remain. Final settings12 WCAG-tagged scans0/223 retain24 disabled Undo/Redo span labels,12 short-text badges,161 overlap and26 partially obscured contexts. Explicit reload observes owner revocation; no live cross-client revocation claim. Actual18 public images and four final PDF comparisons reviewed, not all60 pages. Fresh fidelity16/36+8/24 preserves exact tolerances, no production font/photo/print or provider clearance. RD21/old orphan/concurrency/partial-write/production obligations unchanged; library server-template metadata localisation remains P7-11 residual.
|
||||
|
||||
### P7-05 Discovery evidence
|
||||
|
||||
[Discovery results](p7-05/evidence/discovery-results.json) repair reproduced saved-to-manual context, held old rows, unrelated Retry, edited-filter loss, duplicate mutations, unnamed spinner and Cancel/removed-control focus in the local owner. New18 all-enabled scans0/81 retain18 badge,57 overlapped contrast and6 modal-focus incompletes; existing9scans0/52 remain. Keyboard trap/return/next Tab and moved-focus tests are automated Chromium evidence, not M01-M12 human/device/zoom acceptance. NAV/provider delivery/model calls were withheld; UI-only intercepted dismiss204 is not persisted-run/history proof. No inherited debt or whole PRES group is closed by this slice.
|
||||
|
||||
@@ -152,3 +152,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### P7-04 public finite disposition
|
||||
|
||||
[Public results](p7-04/evidence/public-results.json) establish C05/C12/E09 anonymous sharing/download/revocation/rotated-slug/private-boundary and unchanged source/settings/version/sibling safeguards. Fresh38/3/native8/fidelity16/36+8/24/types/lint/build pass;18 PNGs actually inspected. Sandbox without scripts retains12 frame-tested incompletes; final settings12 WCAG-tagged scans0/223 remain qualified. All57 whole statuses stay56 PARTIAL/C11 DEFERRED,32 routes retained. Derived673/93 and249 are not fresh integrated totals. M09/M11/human/device/output/provider/release obligations remain; production NO-GO/P5-13 withheld/Phase8 separate. Public final hygiene and normal push pending; library fcacae7 verified.
|
||||
|
||||
### P7-05 Discovery finite disposition
|
||||
|
||||
[Discovery results](p7-05/evidence/discovery-results.json): J01/J02/J08/J09/W11 exact query/saved-ID Retry, protected draft/mutation ownership, SQLite CRUD/owner denial, reviewed-import navigation and backend provider-double runner/dismiss safeguards. Fresh21/1/new8+existing4/native/backend14/types/lint/build pass;18 actual new image reviews. New18scans0/81 and existing9scans0/52 remain qualified. No capability/route/API/schema/AI safety change; all57 remain56 PARTIAL/C11 DEFERRED and32 routes. Derived682/93/browser257 are not integrated totals. Companies/Offers/Reminders/manual/provider/release obligations remain; final hygiene/normal push pending.
|
||||
|
||||
@@ -12,8 +12,8 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-01](p7-01/README.md) | Authentication and account-security presentation | Public sub-slice CONDITIONAL GO, pushed8ad720d with direct receipt; account implementation/runtime/actual8-image review CONDITIONAL GO (573/89 +6/22/10); pushedad7b419 with final static PASS/direct receipt19:16:15UTC; main unchanged |
|
||||
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections, hygiene PASS and d21501aa push verified23:55:12UTC. Separate SQLite evidence-list repair91 guards/HTTP1/Release passed, pushed01281f52/direct receipt00:06:47UTC with unfinished Career work preserved. Career affected46/3/native14/preservation2/types/lint/build and actual22-image review pass; implemented checkpoint CONDITIONAL GO with99 incompletes/manual gates. Career final hygiene PASS (four commands0,9docs347links0errors); normally pushed8b4d5c2/direct localHEAD=origin=live receipt05:51:04UTC, clean worktree/main unchanged |
|
||||
| [P7-03](p7-03/README.md) | Career structured editing and import review | Source-ID and structured-editor commits pushed; final import/history runtime/design CONDITIONAL GO: affected76/6, native35, backend98/seeder24, types/lint/build PASS;33/54 final images inspected. Import17 scans0/302, editor13 scans0/377, Career22 scans0/99 retained. Final staged hygiene PASS; normal push5d6e3235 directly verified17:28:54UTC with clean worktree/main unchanged. RD21/human/provider/output gates remain;57/32 unchanged |
|
||||
| [P7-04](p7-04/README.md) | CV library and public entry | Implemented CONDITIONAL GO; library fcacae7 push verified. Public affected38/3/native8/types/lint/build and fresh fidelity16/36+8/24 PASS;18 images actually reviewed, frame-tested12/manual gates retained. Public final hygiene/normal push pending |
|
||||
| P7-05 | Discovery, Companies, Offers and Reminders | Existing domain owners and Jobs return context; planned |
|
||||
| [P7-04](p7-04/README.md) | CV library and public entry | Implemented CONDITIONAL GO; library fcacae7 push verified. Public affected38/3/native8/types/lint/build and fresh fidelity16/36+8/24 PASS;18 images actually reviewed, frame-tested12/manual gates retained. Public final hygiene PASS/normal push bfa0ab6 directly verified22:24:44UTC, clean main unchanged |
|
||||
| [P7-05](p7-05/README.md) | Discovery, Companies, Offers and Reminders | Entry bfa0ab6 verified; Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual PNG reviews/qualified81+52incompletes. Discovery implemented CONDITIONAL GO, hygiene PASS/normal push pending; Companies/Offers/Reminders next as separate commits |
|
||||
| P7-06 | Remaining Application forms and global correspondence | Existing seven-section workspace, immutable packages and draft/send owners; planned |
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
| P7-07 | Documents owner-scoped index | Authenticated metadata-only paginated backend projection first; source destinations from P7-04/06; planned |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# P7-04 — CV library and public entry
|
||||
|
||||
**Implemented scope CONDITIONAL GO; library pushed, public final hygiene PASS and normal push pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): one CV library commit, then one public-entry commit. Reuse the existing gallery, editor, confirmation/prompt, theme and feedback owners.
|
||||
**Implemented scope CONDITIONAL GO; both approved boundaries normally pushed and directly verified.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): one CV library commit, then one public-entry commit. Reuse the existing gallery, editor, confirmation/prompt, theme and feedback owners.
|
||||
|
||||
[Entry evidence](evidence/entry.json) records the actual clean P7-03 normal push5d6e3235, direct development equality/main unchanged and eight exact committed Git-blob baselines. P7-03 affected76/6/native35/backend98/seeder24 and static checks pass with finite visual/axe/manual qualifications. Latest whole frontend573/89/backend1,033/sidecar49 remain inherited;648/93 frontend,233 ordinary browser and1,034 backend are derived expectations. P7-12 must run fresh integrated regression.
|
||||
|
||||
@@ -46,3 +46,5 @@ Meaningful RED4/6 and title RED13/1 are frozen separately. Initial native6/2 ref
|
||||
Peer review found no product defect. The native geometry test now additionally asserts rendered width equals min(A4, viewport minus32px) within1px; focused existing eight-sample journey passes after that assertion, with fresh browser types. Product source/build/fidelity remain unchanged. Seven new screenshots exactly match previously reviewed SHA bytes; the differing1920 capture was displayed/reviewed. Extra eight scans0/8 frame-tested are repeated evidence, not additional cases. The first targeted runner grep matched0 tests/exit1; raw failure retained and exact-title grep corrected, no skip or expected failure.
|
||||
|
||||
Final public [staged hygiene](evidence/public-static-checks.json) PASS at2026-10-12 22:24:04UTC: four commands0,11docs/412 local links0errors,57groups/32routes. Normal development push pending; actual hash and direct remote receipt follow in the next checkpoint.
|
||||
|
||||
Observed normal public push `bfa0ab62f8399398191b020c4eae78fac0bafc9a` at2026-10-12 22:24:44.035140UTC: localHEAD=origin=live, clean worktree/main27657f3 unchanged. P7-04 implemented CONDITIONAL GO; all evidence qualifications remain. Continue approved P7-05 sequence.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-04",
|
||||
"slice": "Public CV entry",
|
||||
"recordedUtc": "2026-10-12T22:21:23.044586+00:00",
|
||||
"acceptance": "IMPLEMENTED_SCOPE_CONDITIONAL_GO_STATIC_PASS_NORMAL_PUSH_PENDING",
|
||||
"acceptance": "IMPLEMENTED_SCOPE_CONDITIONAL_GO_NORMAL_PUSH_VERIFIED",
|
||||
"entryReceipt": {
|
||||
"observedUtc": "2026-10-12T18:03:41.481787+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
@@ -4240,5 +4240,105 @@
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
},
|
||||
"normalPushReceipt": {
|
||||
"observedUtc": "2026-10-12T22:24:44.035140+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"originDevelopment": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"liveDevelopment": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "bfa0ab62f8399398191b020c4eae78fac0bafc9a\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "bfa0ab62f8399398191b020c4eae78fac0bafc9a\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "bfa0ab62f8399398191b020c4eae78fac0bafc9a\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# P7-05 — Discovery, Companies, Offers and Reminders
|
||||
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push pending. Companies, Offers and Reminders remain pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): Discovery, Companies, Offers, then Reminders as four separate normal development commits.
|
||||
|
||||
[Entry evidence](evidence/entry.json) records the actual bfa0ab6 public push, clean direct development equality/main unchanged and13 exact committed source baselines. P7-04 implemented scope is CONDITIONAL GO: affected38/3/public native8/fidelity16/36+8/24/types/lint/build/static checks pass;18 public PNGs actually inspected. Sandbox frame-tested12 and separate settings incompletes223 remain. Whole frontend573/89/backend1,033/sidecar49 are inherited; frontend673/93/browser249/backend1,034 are derived expectations, not integrated reruns.
|
||||
|
||||
| Boundary | Confirmed entry findings / bounded scope | Required exit proof |
|
||||
|---|---|---|
|
||||
| Discovery | Manual search retains saved context; held saved search keeps old rows; global Retry always runs manual search, including mutation failures. Pending controls permit duplicate operations/draft changes. Reuse shared headers, feedback, tokens and original validated saved-list states | Exact saved/manual request ownership, snapshot Retry, explicit mutation recovery/draft preservation, alert/create/delete/dismiss readbacks, backend provider-double/SQLite safeguards, existing owner-denial and saved-list repair, keyboard/focus and finite responsive/theme/locale evidence |
|
||||
| Companies | Pending save and relation-read sequencing/focus need qualification; keep P6 durable draft/error contract and existing company edit/deep-link ownership | Contact/date/source payloads, failed/held saves, relation request changes, owner/tenant guards, locale and mobile presentation |
|
||||
| Offers | Current first25 sample and unsupported deadline sort need truthful labels/effective-contract preservation; avoid silently changing salary display semantics | More-than25 sample/source/currency/period/missing values, supported sort contract, retry/context/navigation and long-label reflow |
|
||||
| Reminders | Long no-wrap rows/chips, hard-coded offsets and pending patches need review; keep all groups/reasons/counts/workflow destinations | Held/failed +3/+7/clear with explicit recovery and owned reload, localised dates/offsets, keyboard/responsive behavior; calendar UTC/date-only DST debt remains |
|
||||
|
||||
PRES-J01/J02/J08/J09/W11 and all57 groups remain binding;32 routes stay. No standalone company-create capability, backend scaling, NAV/model qualification, hidden calendar semantics, followMode composer, schema change, production deployment or Phase8 customisation. M01–M12 human/device/output/provider requirements and all release blockers remain. Production NO-GO/main unchanged.
|
||||
|
||||
## Discovery implemented result
|
||||
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push pending. Companies, Offers and Reminders remain pending.** [Finite results](evidence/discovery-results.json) retain exact final source/build/raw outcomes and failed attempts. Fresh affected **21/1**, new ordinary browser **8**, existing ordinary browser **4**, backend **14**, app/browser TypeScript, lint and production build pass. The original12 unit cases remain; +9/0 gives **682/93 frontend derived only**. Browser+8 gives **257 derived only**; no fresh integrated totals until P7-12.
|
||||
|
||||
Shell retains its H1 and intro. Saved searches use the established section title/tokens; results use a shared H2, feedback and named focusable region. Job cards are named articles with H3 headings and wrapping actions. Manual search clears saved context; switching saved sources clears old rows before the pending response. One synchronous operation guard prevents duplicate requests and locks the three draft fields and dependent mutations. Read Retry uses the exact submitted GET or bodyless saved-ID POST while preserving both later unsaved filter drafts. Mutation failures preserve their drafts/current rows and require the original action, without an unrelated manual-search Retry. Semantic fallback keys retranslate without refetching.
|
||||
|
||||
The unnamed decorative search-button spinner is hidden from assistive technology; the shared results progress/live status remains named. Delete keeps its invoking control enabled until confirmation is accepted, preserving Cancel focus. After successful Delete/Dismiss removes that control, a local committed-removal effect restores the results region only when focus fell to body. Native proof covers Cancel trap/return, fallback, next Tab and deliberately moved header focus surviving a held response. No shared dialog framework or save semantics change.
|
||||
|
||||
All **18 new final PNGs were displayed and reviewed**;13 existing-regression captures are retained without a claimed visual review. Finite320/360/390/768/1024/1280 landscape/1440/1920 samples cover EN/Bokmål, light/dark, long names, two job cards, empty/held/error states and visible focus with root overflow0. New18 all-enabled scans have **0 violations/81 incompletes**:18 badge short-text,57 overlapped contrast and6 focusable-modal-open. Existing9 WCAG-tagged scans retain **0/52** (9 badge/43 overlap). The six focus checks concern hidden retained Sort menus after keyboard selection, not Delete dialogs. Exact combobox focus returns after Enter; hidden-option Tab reachability and human announcements remain qualified. The 57 new overlap nodes comprise42 inputs/12 Sort controls/3 buttons; all related-node arrays are empty, so the header is context rather than a proven cause. An unresolved ratio0 is not a measured contrast ratio. Fixed-shell paint at the current scroll position appears in full-page captures; complete vectors remain for contextual/manual qualification, with no disabled rule. Human screen-reader, physical touch/software keyboard and real browser zoom acceptance remain open.
|
||||
|
||||
Real owner SQLite list/create/toggle/delete and reload, crafted foreign/anonymous denial, saved-list failure recovery and provider-double runner/history/dismiss tests pass. Browser vacancy search/run are explicit fixtures and never call NAV; real missing-result Dismiss404 and two intercepted204 UI recovery paths are distinguished. Stored native counts remain unchanged for those UI-only204 responses; successful provider/history/dismiss persistence or alert delivery is not claimed from browser fixtures.
|
||||
|
||||
Meaningful initial unit RED11/10, peer Retry RED20/1, first native3/5 and focus RED6/2 are frozen. Exact fixture corrections account for legitimate UpdatedAt order and MUI label-plus-value accessible naming; timeout remains unchanged. Final21/1 and8+4 pass with no skipped/expected-failure/retry/relaxed assertions. All57 whole-group statuses remain56 PARTIAL/C11 DEFERRED and32 routes retained. Main stays27657f3/production NO-GO; model review remains withheld and Phase8 separate.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-05",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-12T22:49:01.660502+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:48:58.520206+00:00",
|
||||
"finishedUtc": "2026-10-12T22:49:00.985541+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T224858Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:49:00.986584+00:00",
|
||||
"finishedUtc": "2026-10-12T22:49:01.091079+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T224858Z/working-whitespace.log",
|
||||
"logSha256Raw": "86c0440874db31c69c1ce93cac811a175037edac38f86997d32a8458c2e604ca"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:49:01.091579+00:00",
|
||||
"finishedUtc": "2026-10-12T22:49:01.170277+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T224858Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:49:01.170277+00:00",
|
||||
"finishedUtc": "2026-10-12T22:49:01.317878+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T224858Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d951534e6750e6ac1dde3d9532c55116f8f7c020f4b8432b864171c1a339de87"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 4,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 12,
|
||||
"relativeLinks": 420,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 32
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"checkpoint": "P7-05",
|
||||
"entry": "CONDITIONAL GO; approved four domain boundaries pending",
|
||||
"publicPushReceipt": {
|
||||
"observedUtc": "2026-10-12T22:24:44.035140+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"originDevelopment": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"liveDevelopment": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "bfa0ab62f8399398191b020c4eae78fac0bafc9a\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "bfa0ab62f8399398191b020c4eae78fac0bafc9a\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "bfa0ab62f8399398191b020c4eae78fac0bafc9a\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"baselines": [
|
||||
{
|
||||
"source": "job-tracker-ui/src/views/JobDiscoveryPage.tsx",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/views/JobDiscoveryPage.tsx",
|
||||
"sha256": "080aad99071cb3b5fbbaf9b7eff6f0dc386c835e1fdacb2e622b79d6db8cfa93",
|
||||
"bytes": 15498,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/components/CompaniesTable.tsx",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/components/CompaniesTable.tsx",
|
||||
"sha256": "f95c5a8aff51cf5d75ae40227c1d07e3277cfdf5eabab3cb6d386cfc3425c72b",
|
||||
"bytes": 16512,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/views/OfferComparisonPage.tsx",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/views/OfferComparisonPage.tsx",
|
||||
"sha256": "2886abcc12451fbd1af48e174195b2769d3d2d4ffaa24d33205ef6ba770e4af9",
|
||||
"bytes": 4633,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/components/RemindersView.tsx",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/components/RemindersView.tsx",
|
||||
"sha256": "42780d9229c1ff3b56b33562da58ca17ab182b2397dafeaf9cdd7d3fb04a1f0c",
|
||||
"bytes": 6075,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/job-discovery.test.tsx",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/job-discovery.test.tsx",
|
||||
"sha256": "7556e203ac5d94f19ee00858f360c3beee243a01749db2496f1bea13bae32602",
|
||||
"bytes": 9697,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/e2e/job-discovery.spec.ts",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/e2e/job-discovery.spec.ts",
|
||||
"sha256": "758fcc371d68f14ac246e9d2c14c6f2184b699d987a5396e2a28f18c6d32bd71",
|
||||
"bytes": 4306,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/e2e/saved-search-repair.spec.ts",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/e2e/saved-search-repair.spec.ts",
|
||||
"sha256": "d18d064da6ebba4ba9096076c061addfdf103c9ed4cc504d07e3263a494d61f8",
|
||||
"bytes": 19805,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/App.tsx",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/App.tsx",
|
||||
"sha256": "b7032f52def046a6f6104839a0acb6e6bd8c85b9cdfb73b6912a0923a85a0b16",
|
||||
"bytes": 27290,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/i18n/translations.ts",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/job-tracker-ui/src/i18n/translations.ts",
|
||||
"sha256": "43a41622649cd9dea96964f21cada3aa63ac04bac07d9ee3aaa899aa800ca61e",
|
||||
"bytes": 314753,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "JobTrackerApi/Controllers/JobDiscoveryController.cs",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/JobTrackerApi/Controllers/JobDiscoveryController.cs",
|
||||
"sha256": "1ffb95a866dc821c468df8dfbbec75d4c7b1656bfb791109c43915205f4d6ee6",
|
||||
"bytes": 7957,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "JobTrackerApi.Tests/JobDiscoveryControllerTests.cs",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs",
|
||||
"sha256": "992ef6456e5665000a861445d62be816622ae3474af2017c8e9342b56a98f7c2",
|
||||
"bytes": 6027,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs",
|
||||
"sha256": "cce4ec28606b53a4756f6056afbc4ae626cdcb6f624a85ede0268cacd6c1bf50",
|
||||
"bytes": 12579,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "JobTrackerApi.Tests/SavedJobSearchAlertTests.cs",
|
||||
"blobCommit": "bfa0ab62f8399398191b020c4eae78fac0bafc9a",
|
||||
"path": ".artifacts/phase7/p7-05/entry-source/JobTrackerApi.Tests/SavedJobSearchAlertTests.cs",
|
||||
"sha256": "bd9d5530a5af618c60be4b16eaf8f46c8e13ba9cc39f10eab95690ca0e820d51",
|
||||
"bytes": 4602,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"frontendDerivedOnly": "673/93",
|
||||
"browserDerivedOnly": 249,
|
||||
"backendDerivedOnly": 1034,
|
||||
"latestWholeFrontendInherited": "573/89",
|
||||
"latestWholeBackendInherited": 1033,
|
||||
"sidecarInherited": 49
|
||||
},
|
||||
"dependencies": "Discovery→Companies→Offers→Reminders four commits; no live NAV service/model/backend scaling/calendar semantic change; preserve32 routes/57groups/P6 durable company errors/saved-list repair. P5-13 withheld/P7-06F deferred/production NO-GO/Phase8 separate."
|
||||
}
|
||||
@@ -86,8 +86,8 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-01 public authentication and separate account/security presentation (implemented scope CONDITIONAL GO; account pushedad7b419/direct receipt19:16:15UTC): public sub-slice CONDITIONAL GO, pushed `8ad720d`, direct remote=HEAD verified2026-10-11 18:50:24UTC, main unchanged; affected61/native22/isolated SMTP2/helper6/auth subset87 pass. [Checkpoint record](../redesign/phase-7/p7-01/README.md) retains manual/provider/production gates. Fresh account full573/89, native6/public22/auth-boundaries10 and types/lint/build pass; all eight final post-copy PNGs actually inspected and source/context evidence frozen. Account85 and public31 current axe incompletes remain qualified; final staged finite hygiene passed; normal account pushad7b419 directly verified2026-10-11 19:16:15UTC with localHEAD=origin=live equality and clean worktree at observation, main unchanged.
|
||||
- [x] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push8b4d5c2 directly verified05:51:04UTC, localHEAD=origin=live/clean worktree/main unchanged. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
|
||||
- [x] P7-03 Career structured editor and import/history: Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
- [x] P7-04 CV library and public entry implemented scope CONDITIONAL GO: library fcacae7 pushed/verified; public38/3 affected/native8/fidelity16/36+8/24/types/lint/build PASS,18 actual public images reviewed. Public final hygiene/normal push pending; sandbox frame-tested12 and human/device/production qualifications retained.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders.
|
||||
- [x] P7-04 CV library and public entry implemented scope CONDITIONAL GO: library fcacae7 pushed/verified; public38/3 affected/native8/fidelity16/36+8/24/types/lint/build PASS,18 actual public images reviewed. Public final hygiene PASS and bfa0ab6 normal push verified; sandbox frame-tested12 and human/device/production qualifications retained.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders: Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual new PNG reviews,81+52 axe incompletes retained. Discovery hygiene PASS/normal push pending; other three domains pending.
|
||||
- [ ] P7-06 remaining Application forms and global correspondence.
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
- [ ] P7-07 owner-scoped metadata projection, then Documents interface.
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const savedUrl = `${apiUrl}/job-discovery/saved-searches`;
|
||||
const searchUrl = `${apiUrl}/job-discovery/search`;
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; resultCount: number; dismissedCount: number; lastRunAtUtc: string | null };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type RequestRecord = { method: string; path: string; query: Record<string, string>; body: unknown };
|
||||
type Session = { owner: Owner; requests: RequestRecord[] };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const providerBoundary = 'Saved-search list/create/alert PATCH/delete and readbacks use the real isolated owner API and disposable SQLite. Browser vacancy-provider /search and owner /saved-searches/{id}/run responses are intercepted because the server provider has an external NAV base URL. Intercepted runs do not create SavedJobSearchResults: successful intercepted Dismiss establishes presentation only, never runner/history/dismiss persistence or alert delivery. A real missing-result Dismiss 404 is exercised separately. The unchanged saved-search-repair suite and backend fake-provider SQLite tests retain owner/anonymous and real runner/history contracts. External browser traffic and unregistered provider-dependent requests are blocked.';
|
||||
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> {
|
||||
expect(response.status(), `${response.url()} returns ${status}`).toBe(status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
||||
expect(csrf).toBeDefined();
|
||||
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
|
||||
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const admin = await playwright.request.newContext();
|
||||
const requests: RequestRecord[] = [];
|
||||
const blockedProviderRequests: string[] = [];
|
||||
const blockedExternalRequests: string[] = [];
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.startsWith('/api/job-discovery/')) return;
|
||||
requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams),
|
||||
body: request.postData() ? request.postDataJSON() : null });
|
||||
});
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const email = `p7-discovery-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const owner = await login(page.request, email);
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname) && !['data:', 'blob:', 'about:'].includes(url.protocol)) {
|
||||
blockedExternalRequests.push(url.origin + url.pathname);
|
||||
return route.abort();
|
||||
}
|
||||
if (url.pathname === '/api/job-discovery/search' || /^\/api\/job-discovery\/saved-searches\/\d+\/run$/.test(url.pathname)) {
|
||||
blockedProviderRequests.push(`${route.request().method()} ${url.pathname}`);
|
||||
return route.fulfill({ status: 502, json: { message: 'Unregistered provider request blocked by isolated test guard' } });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await provide({ owner, requests });
|
||||
expect(blockedProviderRequests, 'Every provider-dependent request must have an explicit fixture; none reaches the real provider').toEqual([]);
|
||||
} finally {
|
||||
await info.attach('discovery-request-boundary', { body: JSON.stringify({ providerBoundary, requests, blockedProviderRequests, blockedExternalRequests,
|
||||
qualification: 'Browser request log contains only method/path/query/JSON payload, with no cookies or authorization/CSRF/session headers. Direct API fixture reads are verified in individual tests; this log is not a server outbound-traffic measurement.' }), contentType: 'application/json' });
|
||||
await admin.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function nativeList(api: APIRequestContext) {
|
||||
return json<SavedSearch[]>(await api.get(savedUrl));
|
||||
}
|
||||
|
||||
async function createSaved(page: Page, owner: Owner, name: string, query = 'backend', location = 'Oslo') {
|
||||
return json<SavedSearch>(await page.request.post(savedUrl, { headers: owner.headers, data: { name, query, location } }), 201);
|
||||
}
|
||||
|
||||
function vacancy(id: string, title = `Provider fixture ${id}`) {
|
||||
return { id, title, company: 'Eksempelbedriften for samfunnskritiske systemer Æ Ø Å', location: 'TRONDHEIM',
|
||||
modifiedAt: '2026-10-09T10:00:00Z', deadline: '2026-10-20T23:59:59Z',
|
||||
url: `https://arbeidsplassen.nav.no/stillinger/stilling/${encodeURIComponent(id)}`, source: 'nav', sourceName: 'NAV Arbeidsplassen',
|
||||
acquisitionType: 'searched', retrievedAt: '2026-10-10T10:00:00Z', countryCode: 'NO' };
|
||||
}
|
||||
type Vacancy = ReturnType<typeof vacancy>;
|
||||
|
||||
async function runPayload(page: Page, saved: SavedSearch, jobs: Vacancy[]) {
|
||||
const current = (await nativeList(page.request)).find(row => row.id === saved.id);
|
||||
expect(current).toBeDefined();
|
||||
return { search: current, jobs: jobs.map(job => ({ job, isNew: true, isDismissed: false })) };
|
||||
}
|
||||
|
||||
function savedGroup(page: Page, saved: SavedSearch) { return page.getByRole('group', { name: saved.name, exact: true }); }
|
||||
function article(page: Page, job: Vacancy) { return page.getByRole('article', { name: job.title, exact: true }); }
|
||||
function results(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].jobDiscoveryResults, exact: true }); }
|
||||
function count(requests: RequestRecord[], method: string, path: string) { return requests.filter(request => request.method === method && request.path === path).length; }
|
||||
function response(page: Page, method: string, url: string) { return page.waitForResponse(result => result.url() === url && result.request().method() === method); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function keyboardActivate(page: Page, locator: Locator, key = 'Enter') { await locator.focus(); await expect(locator).toBeFocused(); await page.keyboard.press(key); }
|
||||
|
||||
async function recordFocus(page: Page, info: TestInfo, state: string) {
|
||||
const focused = await page.evaluate(() => {
|
||||
const element = document.activeElement;
|
||||
return { tag: element?.tagName, id: element?.id, role: element?.getAttribute('role'),
|
||||
label: element?.getAttribute('aria-label'), text: element?.textContent?.trim().slice(0, 140) };
|
||||
});
|
||||
await info.attach(`discovery-focus-${state}`, { body: JSON.stringify({ state, focused,
|
||||
qualification: 'Actual document.activeElement in isolated Chromium, not a human screen-reader or physical-device acceptance claim.' }), contentType: 'application/json' });
|
||||
}
|
||||
|
||||
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
|
||||
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(({ language, theme }) => {
|
||||
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
|
||||
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/discover?source=phase7-discovery#preserved');
|
||||
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
|
||||
if (stored.language !== sample.language || stored.theme !== sample.theme) {
|
||||
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample);
|
||||
await page.reload();
|
||||
}
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
await expect(page.getByRole('heading', { level: 1, name: translations[sample.language].discoverJobs, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status', { name: translations[sample.language].jobDiscoverySavedLoading, exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function locked(page: Page, language: Sample['language'] = 'en') {
|
||||
const t = translations[language];
|
||||
for (const label of [t.jobDiscoveryRoleCompany, t.jobDiscoveryMunicipality, t.jobDiscoverySavedName]) await expect(page.getByLabel(label, { exact: true })).toBeDisabled();
|
||||
for (const name of [t.jobDiscoverySearchNav, t.jobDiscoverySaveSearch, t.jobDiscoveryRunSaved, t.adminUsersDelete, t.jobDiscoveryDismiss]) {
|
||||
for (const button of await page.getByRole('button', { name, exact: true }).all()) await expect(button).toBeDisabled();
|
||||
}
|
||||
for (const control of await page.getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }).all()) await expect(control).toBeDisabled();
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
|
||||
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
overflowElements: [...document.querySelectorAll('body *')]
|
||||
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
|
||||
.map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; })
|
||||
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20),
|
||||
}));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-discovery-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(),
|
||||
axe: { scope: 'All enabled rules plus target-size; complete violations and incomplete findings retained', violations: axe.violations, incomplete: axe.incomplete,
|
||||
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, providerBoundary,
|
||||
qualifications: ['Finite browser states, not the Cartesian product of every width/language/theme/state.',
|
||||
'Automated local Chromium and reduced-motion media only; axe incomplete findings require triage and do not establish human screen-reader acceptance.',
|
||||
'Physical touch/software keyboard, actual browser zoom, deployed fonts/photos, real provider/alert delivery and production acceptance remain unverified.'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow, `${name} must reflow without app chrome overflow`).toBe(0);
|
||||
expect(axe.violations, `${name} complete axe violations`).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-05 Discovery] saved A to manual search clears saved-result context and preserves native saved rows', async ({ page, session }, info) => {
|
||||
const t = translations.en;
|
||||
const saved = await createSaved(page, session.owner, 'Recorded saved search A');
|
||||
const savedJob = vacancy('saved-a'); const manualJob = vacancy('manual-only');
|
||||
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [savedJob]) }));
|
||||
await page.route(`${searchUrl}**`, route => route.fulfill({ json: [manualJob] }));
|
||||
await enter(page, session.owner);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect(article(page, savedJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toBeVisible();
|
||||
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('manual analyst');
|
||||
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Bergen');
|
||||
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
|
||||
await expect(article(page, manualJob)).toBeVisible();
|
||||
await expect(article(page, savedJob)).toHaveCount(0);
|
||||
await expect(results(page).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toHaveCount(0);
|
||||
expect(session.requests.filter(request => request.path === '/api/job-discovery/search')).toEqual([
|
||||
{ method: 'GET', path: '/api/job-discovery/search', query: { q: 'manual analyst', location: 'Bergen' }, body: null },
|
||||
]);
|
||||
expect(session.requests.filter(request => request.path.endsWith('/dismiss'))).toEqual([]);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await expect(page).toHaveURL(/\/discover\?source=phase7-discovery#preserved$/);
|
||||
await capture(page, info, desktop, 'saved-to-manual');
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] held saved B removes A rows, locks all mutations and starts one request', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const t = translations.en;
|
||||
const a = await createSaved(page, session.owner, 'Recorded saved search A', 'A role', 'Oslo');
|
||||
const b = await createSaved(page, session.owner, 'Recorded saved search B', 'B role', 'Bergen');
|
||||
const aJob = vacancy('held-old-a'); const bJob = vacancy('held-current-b'); const gate = hold();
|
||||
let starts = 0;
|
||||
await page.route(`${savedUrl}/${a.id}/run`, async route => route.fulfill({ json: await runPayload(page, a, [aJob]) }));
|
||||
await page.route(`${savedUrl}/${b.id}/run`, async route => { starts++; await gate.pending; await route.fulfill({ json: await runPayload(page, b, [bJob]) }); });
|
||||
await enter(page, session.owner, { ...desktop, width: 360, height: 844 });
|
||||
await keyboardActivate(page, savedGroup(page, a).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect(article(page, aJob)).toBeVisible();
|
||||
try {
|
||||
await keyboardActivate(page, savedGroup(page, b).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect.poll(() => starts).toBe(1);
|
||||
await expect(article(page, aJob)).toHaveCount(0);
|
||||
await expect(article(page, bJob)).toHaveCount(0);
|
||||
await locked(page);
|
||||
await expect(results(page).getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
|
||||
await expect(results(page).getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
await expect(results(page).getByRole('status')).toHaveText(t.loading);
|
||||
await page.keyboard.press('Enter');
|
||||
expect(starts).toBe(1);
|
||||
await capture(page, info, { ...desktop, width: 360, height: 844 }, 'held-saved-b');
|
||||
} finally { gate.release(); }
|
||||
await expect(article(page, bJob)).toBeVisible();
|
||||
await expect(article(page, aJob)).toHaveCount(0);
|
||||
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue(b.query);
|
||||
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue(b.location);
|
||||
await expect(article(page, bJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toBeEnabled();
|
||||
expect(starts).toBe(1);
|
||||
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
|
||||
expect(await nativeList(page.request)).toEqual([b, a]);
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] failed saved-run Retry posts the same ID with stable keyboard focus and held progress', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const t = translations.en; const sample = { ...desktop, theme: 'dark' as const, width: 390, height: 844 };
|
||||
const saved = await createSaved(page, session.owner, 'Recorded retry saved B', 'saved backend', 'Bergen');
|
||||
const job = vacancy('retry-saved-b'); const gate = hold(); let runs = 0;
|
||||
await page.route(`${savedUrl}/${saved.id}/run`, async route => {
|
||||
runs++;
|
||||
if (runs === 1) return route.fulfill({ status: 500, json: { detail: 'Controlled provider read failure; do not expose internal detail' } });
|
||||
await gate.pending; return route.fulfill({ json: await runPayload(page, saved, [job]) });
|
||||
});
|
||||
await enter(page, session.owner, sample);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect(results(page).getByText(t.jobDiscoveryUnavailable, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Controlled provider read failure/)).toHaveCount(0);
|
||||
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('unsubmitted different query');
|
||||
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('unsubmitted Ålesund draft');
|
||||
try {
|
||||
await keyboardActivate(page, results(page).getByRole('button', { name: t.retry, exact: true }));
|
||||
await expect.poll(() => runs).toBe(2);
|
||||
await expect(results(page)).toBeFocused();
|
||||
await locked(page);
|
||||
await expect(results(page).getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
|
||||
await expect(results(page).getByRole('status')).toHaveText(t.loading);
|
||||
await expect(results(page).getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
await page.keyboard.press('Enter'); expect(runs).toBe(2);
|
||||
await capture(page, info, sample, 'saved-retry-held');
|
||||
} finally { gate.release(); }
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
await expect(results(page)).toBeFocused();
|
||||
await expect(results(page).getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('unsubmitted different query');
|
||||
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue('unsubmitted Ålesund draft');
|
||||
expect(runs).toBe(2);
|
||||
expect(count(session.requests, 'POST', `/api/job-discovery/saved-searches/${saved.id}/run`)).toBe(2);
|
||||
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await capture(page, info, sample, 'saved-retry-recovered');
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] mutation failures retain drafts and current rows without offering an unrelated manual-search retry', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const t = translations.en;
|
||||
const saved = await createSaved(page, session.owner, 'Recorded mutation recovery');
|
||||
const job = vacancy('mutation-current');
|
||||
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job]) }));
|
||||
let createFails = true; let alertFails = true; let deleteFails = true;
|
||||
await page.route(savedUrl, route => route.request().method() === 'POST' && createFails
|
||||
? route.fulfill({ status: 500, json: { message: 'Controlled create failure' } }) : route.continue());
|
||||
await page.route(`${savedUrl}/${saved.id}`, route => (route.request().method() === 'PATCH' && alertFails) || (route.request().method() === 'DELETE' && deleteFails)
|
||||
? route.fulfill({ status: 500, json: { message: 'Controlled mutation failure' } }) : route.continue());
|
||||
await enter(page, session.owner);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('retained draft role');
|
||||
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Ålesund');
|
||||
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill('Retained failed create draft');
|
||||
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
|
||||
await expect(page.getByText(t.jobDiscoverySaveSearchFailed, { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel(t.jobDiscoverySavedName, { exact: true })).toHaveValue('Retained failed create draft');
|
||||
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('retained draft role');
|
||||
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
createFails = false;
|
||||
const createdResponse = response(page, 'POST', savedUrl);
|
||||
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
|
||||
const created = await json<SavedSearch>(await createdResponse, 201);
|
||||
expect(created).toMatchObject({ name: 'Retained failed create draft', query: 'retained draft role', location: 'Ålesund' });
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
|
||||
await expect(page.getByText(t.jobDiscoveryUpdateAlertsFailed, { exact: true })).toBeVisible();
|
||||
await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked();
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
alertFails = false;
|
||||
const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
|
||||
const updated = await json<SavedSearch>(await updatedResponse);
|
||||
expect(updated).toEqual({ ...saved, isActive: false });
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
|
||||
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
|
||||
await expect(page.getByText(t.jobDiscoveryDeleteSavedFailed, { exact: true })).toBeVisible();
|
||||
await expect(savedGroup(page, saved)).toBeVisible();
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
// Native listing sorts by UpdatedAt, and pausing the older row updates that timestamp.
|
||||
// Compare every DTO value without inventing creation-order semantics for this readback.
|
||||
expect((await nativeList(page.request)).sort((left, right) => left.id - right.id)).toEqual([created, updated].sort((left, right) => left.id - right.id));
|
||||
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
|
||||
await capture(page, info, desktop, 'mutation-failure-retains-context');
|
||||
deleteFails = false;
|
||||
const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
|
||||
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
|
||||
expect((await deletedResponse).status()).toBe(204);
|
||||
await expect(savedGroup(page, saved)).toHaveCount(0);
|
||||
expect(await nativeList(page.request)).toEqual([created]);
|
||||
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const t = translations.nb; const sample: Sample = { language: 'nb', theme: 'light', width: 360, height: 844 };
|
||||
const currentJob = vacancy('native-delete-current-manual');
|
||||
await page.route(`${searchUrl}**`, route => route.fulfill({ json: [currentJob] }));
|
||||
const createGate = hold(); const deleteGate = hold(); let creates = 0; let deletes = 0;
|
||||
await page.route(savedUrl, async route => {
|
||||
if (route.request().method() === 'POST') { creates++; await createGate.pending; }
|
||||
return route.continue();
|
||||
});
|
||||
await enter(page, session.owner, sample);
|
||||
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
|
||||
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill(' utvikler ');
|
||||
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill(' Tromsø ');
|
||||
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill(' Gjennomgått utviklersøk Æ Ø Å ');
|
||||
const createdResponse = response(page, 'POST', savedUrl);
|
||||
try {
|
||||
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
|
||||
await expect.poll(() => creates).toBe(1);
|
||||
await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(creates).toBe(1);
|
||||
await capture(page, info, sample, 'native-create-held');
|
||||
} finally { createGate.release(); }
|
||||
const saved = await json<SavedSearch>(await createdResponse, 201);
|
||||
expect(saved).toMatchObject({ name: 'Gjennomgått utviklersøk Æ Ø Å', query: 'utvikler', location: 'Tromsø', isActive: true, resultCount: 0, dismissedCount: 0 });
|
||||
await expect(savedGroup(page, saved)).toBeVisible();
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await page.reload(); await expect(savedGroup(page, saved)).toBeVisible();
|
||||
for (const isActive of [false, true]) {
|
||||
const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
|
||||
expect(await json<SavedSearch>(await updatedResponse)).toEqual({ ...saved, isActive });
|
||||
expect(await nativeList(page.request)).toEqual([{ ...saved, isActive }]);
|
||||
await page.reload();
|
||||
await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked({ checked: isActive });
|
||||
}
|
||||
// Keep a legitimate results control after deleting the saved row so the next Tab
|
||||
// has a concrete destination inside the stable region rather than browser chrome.
|
||||
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
|
||||
await expect(article(page, currentJob)).toBeVisible();
|
||||
const deleteButton = savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true });
|
||||
await keyboardActivate(page, deleteButton);
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText(t.jobDiscoveryDeleteSavedConfirm.replace('{name}', saved.name), { exact: true })).toBeVisible();
|
||||
const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
|
||||
await expect(cancel).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(dialog.getByRole('button', { name: t.adminUsersDelete, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Tab'); await expect(cancel).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toHaveCount(0); await expect(deleteButton).toBeFocused();
|
||||
expect(count(session.requests, 'DELETE', `/api/job-discovery/saved-searches/${saved.id}`)).toBe(0);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await page.route(`${savedUrl}/${saved.id}`, async route => {
|
||||
if (route.request().method() === 'DELETE') { deletes++; await deleteGate.pending; }
|
||||
return route.continue();
|
||||
});
|
||||
const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
|
||||
try {
|
||||
await keyboardActivate(page, deleteButton);
|
||||
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
|
||||
await expect.poll(() => deletes).toBe(1);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(deletes).toBe(1);
|
||||
await capture(page, info, sample, 'native-delete-held');
|
||||
} finally { deleteGate.release(); }
|
||||
expect((await deletedResponse).status()).toBe(204);
|
||||
await expect(savedGroup(page, saved)).toHaveCount(0);
|
||||
await recordFocus(page, info, 'native-delete-control-removed');
|
||||
await expect(results(page, 'nb')).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await recordFocus(page, info, 'native-delete-next-tab');
|
||||
await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
|
||||
await expect(article(page, currentJob)).toBeVisible();
|
||||
expect(await nativeList(page.request)).toEqual([]);
|
||||
await page.reload(); await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
|
||||
expect(creates).toBe(1); expect(deletes).toBe(1);
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-result denial and qualifies intercepted UI recovery', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const t = translations.en; const sample = { ...desktop, theme: 'dark' as const, width: 390, height: 844 };
|
||||
const saved = await createSaved(page, session.owner, 'Recorded dismiss owner context');
|
||||
const job = vacancy('p7-dismiss-encoded Æ Ø Å');
|
||||
const movedFocusJob = vacancy('p7-dismiss-second-moved-focus');
|
||||
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job, movedFocusJob]) }));
|
||||
await enter(page, session.owner, sample);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
const dismissUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(job.id)}/dismiss`;
|
||||
const deniedResponse = response(page, 'PATCH', dismissUrl);
|
||||
await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
|
||||
expect((await deniedResponse).status()).toBe(404);
|
||||
await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toBeVisible();
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await capture(page, info, sample, 'dismiss-native-missing-denied');
|
||||
const gate = hold(); let starts = 0;
|
||||
await page.route(dismissUrl, async route => {
|
||||
expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
|
||||
starts++; await gate.pending; return route.fulfill({ status: 204 });
|
||||
});
|
||||
try {
|
||||
await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
|
||||
await expect.poll(() => starts).toBe(1); await locked(page); await page.keyboard.press('Enter'); expect(starts).toBe(1);
|
||||
} finally { gate.release(); }
|
||||
await expect(article(page, job)).toHaveCount(0);
|
||||
await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toHaveCount(0);
|
||||
await recordFocus(page, info, 'dismiss-control-removed');
|
||||
await expect(results(page)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await recordFocus(page, info, 'dismiss-next-tab');
|
||||
await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
|
||||
await expect(article(page, movedFocusJob)).toBeVisible();
|
||||
const movedGate = hold();
|
||||
const movedUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(movedFocusJob.id)}/dismiss`;
|
||||
let movedStarts = 0;
|
||||
await page.route(movedUrl, async route => {
|
||||
expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
|
||||
movedStarts++; await movedGate.pending; return route.fulfill({ status: 204 });
|
||||
});
|
||||
const headerControl = page.locator('header').getByRole('button', { name: 'Norsk', exact: true });
|
||||
try {
|
||||
await keyboardActivate(page, article(page, movedFocusJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
|
||||
await expect.poll(() => movedStarts).toBe(1); await locked(page);
|
||||
await expect(headerControl).toBeEnabled(); await headerControl.focus(); await expect(headerControl).toBeFocused();
|
||||
await recordFocus(page, info, 'held-dismiss-user-moved-focus');
|
||||
} finally { movedGate.release(); }
|
||||
await expect(article(page, movedFocusJob)).toHaveCount(0);
|
||||
await recordFocus(page, info, 'dismiss-complete-user-focus-preserved');
|
||||
await expect(headerControl).toBeFocused();
|
||||
expect(movedStarts).toBe(1);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
|
||||
const patches = session.requests.filter(request => request.path.endsWith('/dismiss'));
|
||||
expect(patches).toHaveLength(3);
|
||||
expect(patches.map(request => request.body)).toEqual([{ isDismissed: true }, { isDismissed: true }, { isDismissed: true }]);
|
||||
await capture(page, info, sample, 'dismiss-intercepted-ui-only');
|
||||
await page.reload();
|
||||
await expect(savedGroup(page, saved).getByText(t.jobDiscoveryTrackedCount.replace('{count}', '0'), { exact: true })).toBeVisible();
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await info.attach('dismiss-persistence-qualification', { body: JSON.stringify({ savedSearchId: saved.id, externalId: job.id,
|
||||
actualMissingResultStatus: 404, interceptedRecoveryStatus: 204, actualReadback: await nativeList(page.request),
|
||||
qualification: 'Provider-run fixture rows do not exist in SQLite. Native 404 proves the missing-result boundary. The next two PATCHes are explicitly intercepted for keyboard/UI recovery, fallback focus and preservation of user-moved focus; unchanged real counts and reload are not successful dismissal persistence evidence.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] eight finite responsive locale/theme states retain source labels and all-enabled accessibility vectors', async ({ page, session }, info) => {
|
||||
test.setTimeout(270_000);
|
||||
const saved = await createSaved(page, session.owner,
|
||||
'Gjennomgått søk etter utviklere for samfunnskritiske tjenester, integrasjoner og offentlige plattformer — Æ Ø Å med lang forklaring',
|
||||
'Senior systemutvikler for robuste og samfunnskritiske plattformer', 'TRONDHEIM');
|
||||
const longJob = vacancy('finite-long', 'Senior systemutvikler for robuste og samfunnskritiske plattformer med ansvar for offentlige tjenester');
|
||||
const shortJob = vacancy('finite-analyst', 'Analyst');
|
||||
let jobs: Vacancy[] = [longJob, shortJob];
|
||||
await page.route(`${searchUrl}**`, route => route.fulfill({ json: jobs }));
|
||||
const samples: Array<Sample & { empty: boolean }> = [
|
||||
{ language: 'en', theme: 'light', width: 320, height: 740, empty: false },
|
||||
{ language: 'nb', theme: 'dark', width: 360, height: 844, empty: false },
|
||||
{ language: 'en', theme: 'dark', width: 390, height: 844, empty: true },
|
||||
{ language: 'nb', theme: 'light', width: 768, height: 1024, empty: false },
|
||||
{ language: 'en', theme: 'light', width: 1024, height: 768, empty: false },
|
||||
{ language: 'nb', theme: 'dark', width: 1280, height: 320, empty: true },
|
||||
{ language: 'en', theme: 'dark', width: 1440, height: 900, empty: false },
|
||||
{ language: 'nb', theme: 'light', width: 1920, height: 1080, empty: false },
|
||||
];
|
||||
for (const sample of samples) await test.step(`${sample.language}-${sample.theme}-${sample.width}-${sample.empty ? 'empty' : 'loaded'}`, async () => {
|
||||
const t = translations[sample.language]; jobs = sample.empty ? [] : [longJob, shortJob];
|
||||
await enter(page, session.owner, sample);
|
||||
await expect(savedGroup(page, saved)).toBeVisible();
|
||||
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('samfunnskritisk');
|
||||
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Trondheim');
|
||||
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
|
||||
if (sample.empty) await expect(results(page, sample.language).getByText(t.jobDiscoveryNoResults, { exact: true })).toBeVisible();
|
||||
else {
|
||||
await expect(article(page, longJob).getByRole('heading', { level: 3, name: longJob.title, exact: true })).toBeVisible();
|
||||
await expect(article(page, longJob).getByText('NAV Arbeidsplassen', { exact: true })).toBeVisible();
|
||||
await expect(article(page, longJob).getByText(t.jobDiscoveryWorkArrangementMissing, { exact: true })).toBeVisible();
|
||||
await expect(article(page, longJob).getByRole('link', { name: t.jobDiscoveryViewListing, exact: true })).toHaveAttribute('href', longJob.url);
|
||||
// MUI includes the selected value in aria-labelledby, so label lookup names
|
||||
// the field precisely without assuming its complete accessible name is label-only.
|
||||
await page.getByLabel(t.jobDiscoverySort, { exact: true }).click();
|
||||
await keyboardActivate(page, page.getByRole('option', { name: t.jobDiscoverySortTitle, exact: true }));
|
||||
await expect(page.getByRole('combobox', { name: `${t.jobDiscoverySort} ${t.jobDiscoverySortTitle}`, exact: true })).toBeFocused();
|
||||
await expect(results(page, sample.language).getByRole('article').first()).toHaveAttribute('aria-label', shortJob.title);
|
||||
}
|
||||
await expect(results(page, sample.language).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toHaveCount(0);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await capture(page, info, sample, sample.empty ? 'finite-empty' : 'finite-loaded');
|
||||
});
|
||||
await info.attach('discovery-finite-samples', { body: JSON.stringify({ samples, providerBoundary,
|
||||
qualification: 'Exactly eight configured width/language/theme states, including two empty states and a 1280×320 landscape state. Both themes and languages are represented without claiming every combination.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Discovery] header locale change retains drafts, selected saved source and rows without discovery refetch', async ({ page, session }, info) => {
|
||||
const en = translations.en; const nb = translations.nb;
|
||||
const saved = await createSaved(page, session.owner, 'Recorded language-independent source', 'canonical saved role', 'Bergen');
|
||||
const job = vacancy('locale-source');
|
||||
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job]) }));
|
||||
await enter(page, session.owner);
|
||||
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: en.jobDiscoveryRunSaved, exact: true }));
|
||||
await expect(article(page, job)).toBeVisible();
|
||||
await page.getByLabel(en.jobDiscoveryRoleCompany, { exact: true }).fill('unsubmitted role Æ Ø Å');
|
||||
await page.getByLabel(en.jobDiscoveryMunicipality, { exact: true }).fill('Ålesund draft');
|
||||
await page.getByLabel(en.jobDiscoverySavedName, { exact: true }).fill('Unsubmitted saved-name draft');
|
||||
const before = [...session.requests];
|
||||
const savedPreference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
|
||||
await keyboardActivate(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true }));
|
||||
expect((await savedPreference).status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
await expect(page.getByLabel(nb.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('unsubmitted role Æ Ø Å');
|
||||
await expect(page.getByLabel(nb.jobDiscoveryMunicipality, { exact: true })).toHaveValue('Ålesund draft');
|
||||
await expect(page.getByLabel(nb.jobDiscoverySavedName, { exact: true })).toHaveValue('Unsubmitted saved-name draft');
|
||||
await expect(article(page, job).getByRole('button', { name: nb.jobDiscoveryDismiss, exact: true })).toBeVisible();
|
||||
await expect(savedGroup(page, saved).getByRole('button', { name: nb.jobDiscoveryRunSaved, exact: true })).toBeVisible();
|
||||
await expect(savedGroup(page, saved).getByRole('switch', { name: nb.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked();
|
||||
await expect(results(page, 'nb').getByRole('heading', { level: 2, name: nb.jobDiscoveryResults, exact: true })).toBeVisible();
|
||||
expect(session.requests).toEqual(before);
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await capture(page, info, { ...desktop, language: 'nb' }, 'locale-retained-source');
|
||||
});
|
||||
@@ -279,6 +279,7 @@ export const translations = {
|
||||
jobDiscoveryRoleCompany: "Role or company",
|
||||
jobDiscoveryMunicipality: "Municipality",
|
||||
jobDiscoverySearchNav: "Search NAV",
|
||||
jobDiscoveryResults: "Search results",
|
||||
jobDiscoverySourceHelp: "Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL.",
|
||||
jobDiscoveryUnavailable: "NAV job discovery is temporarily unavailable.",
|
||||
jobDiscoveryGuidance: "Search recent active vacancies by role, company, or municipality.",
|
||||
@@ -2920,6 +2921,7 @@ export const translations = {
|
||||
jobDiscoveryRoleCompany: "Rolle eller bedrift",
|
||||
jobDiscoveryMunicipality: "Kommune",
|
||||
jobDiscoverySearchNav: "Søk i NAV",
|
||||
jobDiscoveryResults: "Søkeresultater",
|
||||
jobDiscoverySourceHelp: "Offisielle norske stillinger fra NAV. Stillinger fra FINN, Indeed og LinkedIn kan fortsatt hentes inn via nettadresse.",
|
||||
jobDiscoveryUnavailable: "NAVs jobbsøk er midlertidig utilgjengelig.",
|
||||
jobDiscoveryGuidance: "Søk i nylige aktive stillinger etter rolle, bedrift eller kommune.",
|
||||
|
||||
@@ -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, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
@@ -19,7 +19,7 @@ function searchResponse(data: any[]) {
|
||||
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("saved-searches") ? [] : data } as any));
|
||||
}
|
||||
|
||||
beforeEach(() => { window.localStorage.clear(); searchResponse([]); });
|
||||
beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); searchResponse([]); });
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test("shows honest source metadata and offers the existing reviewed save flow", async () => {
|
||||
@@ -69,7 +69,7 @@ test("sorts returned vacancies by title", async () => {
|
||||
await screen.findByText("2 vacancies found");
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Sort results" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Title A–Z" }));
|
||||
expect(screen.getAllByRole("heading", { level: 6 }).map((element) => element.textContent)).toEqual(["Analyst", "Zoologist"]);
|
||||
expect(screen.getAllByRole("heading", { level: 3 }).map((element) => element.textContent)).toEqual(["Analyst", "Zoologist"]);
|
||||
});
|
||||
|
||||
test("renders the discovery workflow in Norwegian Bokmål", async () => {
|
||||
@@ -155,3 +155,201 @@ test.each([null, { message: "not a list" }, [{ id: 7 }]])("rejects malformed sav
|
||||
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const savedSearch = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 1, dismissedCount: 0 };
|
||||
const discoveredJob = { id: "new-1", title: "Platform Engineer", url: "https://example.test/new-1", source: "nav", countryCode: "NO" };
|
||||
|
||||
function savedRun(saved = savedSearch, job = discoveredJob) {
|
||||
return { data: { search: saved, jobs: [{ job, isNew: true, isDismissed: false }] } };
|
||||
}
|
||||
|
||||
function savedFixture(saved = [savedSearch], jobs: any[] = []) {
|
||||
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("saved-searches") ? saved : jobs } as any));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test("a manual search after saved-search results has no dismiss action belonging to the saved search", async () => {
|
||||
savedFixture([savedSearch], [{ ...discoveredJob, id: "manual-1", title: "Manual vacancy" }]);
|
||||
mockedApi.post.mockResolvedValueOnce(savedRun() as any);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
|
||||
await screen.findByText("Platform Engineer");
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "manual query" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
||||
await screen.findByText("Manual vacancy");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/job-discovery/search", { params: { q: "manual query", location: "Oslo" } });
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("starting saved search B hides saved search A's results and actions while the B response is held", async () => {
|
||||
const second = { ...savedSearch, id: 8, name: "Data Bergen", query: "data", location: "Bergen" };
|
||||
const read = deferred<any>();
|
||||
savedFixture([savedSearch, second]);
|
||||
mockedApi.post.mockResolvedValueOnce(savedRun() as any).mockImplementationOnce(() => read.promise);
|
||||
renderPage();
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "Check for new jobs" }))[0]);
|
||||
await screen.findByText("Platform Engineer");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Check for new jobs" })[1]);
|
||||
expect(mockedApi.post).toHaveBeenLastCalledWith("/job-discovery/saved-searches/8/run");
|
||||
expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Role or company")).toHaveValue("data");
|
||||
expect(screen.getByLabelText("Municipality")).toHaveValue("Bergen");
|
||||
expect(screen.getAllByRole("button", { name: "Check for new jobs" }).every((button) => (button as HTMLButtonElement).disabled)).toBe(true);
|
||||
await act(async () => { read.resolve(savedRun(second, { ...discoveredJob, id: "b-1", title: "Data Engineer" })); });
|
||||
expect(await screen.findByText("Data Engineer")).toBeInTheDocument();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("retrying a failed saved-search run repeats that saved ID instead of running a manual query", async () => {
|
||||
const retry = deferred<any>();
|
||||
savedFixture();
|
||||
mockedApi.post.mockRejectedValueOnce(new Error("synthetic NAV unavailable")).mockImplementationOnce(() => retry.promise);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
|
||||
const retryButton = await screen.findByRole("button", { name: "Retry" });
|
||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "unsubmitted edited query" } });
|
||||
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "unsubmitted Bergen" } });
|
||||
fireEvent.click(retryButton);
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.post.mock.calls.map(([url]) => url)).toEqual(["/job-discovery/saved-searches/7/run", "/job-discovery/saved-searches/7/run"]);
|
||||
expect(mockedApi.get.mock.calls.some(([url]) => url === "/job-discovery/search")).toBe(false);
|
||||
expect(screen.getByLabelText("Role or company")).toHaveValue("unsubmitted edited query");
|
||||
expect(screen.getByLabelText("Municipality")).toHaveValue("unsubmitted Bergen");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
|
||||
await act(async () => { retry.resolve(savedRun()); });
|
||||
expect(await screen.findByText("Platform Engineer")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Role or company")).toHaveValue("unsubmitted edited query");
|
||||
expect(screen.getByLabelText("Municipality")).toHaveValue("unsubmitted Bergen");
|
||||
});
|
||||
|
||||
test("failed saved-search creation retains its draft and recovers only through the save action", async () => {
|
||||
mockedApi.post.mockRejectedValueOnce(new Error("synthetic save failure")).mockResolvedValueOnce({ data: { ...savedSearch, name: "My search", query: "developer", location: "Bergen" } } as any);
|
||||
renderPage();
|
||||
await screen.findByText("No saved searches yet.");
|
||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "developer" } });
|
||||
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "Bergen" } });
|
||||
fireEvent.change(screen.getByLabelText("Search name"), { target: { value: "My search" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save search" }));
|
||||
expect(await screen.findByText("The search could not be saved.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Search name")).toHaveValue("My search");
|
||||
expect(screen.getByLabelText("Role or company")).toHaveValue("developer");
|
||||
expect(screen.getByLabelText("Municipality")).toHaveValue("Bergen");
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save search" }));
|
||||
expect(await screen.findByText("My search")).toBeInTheDocument();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.post).toHaveBeenLastCalledWith("/job-discovery/saved-searches", { name: "My search", query: "developer", location: "Bergen" });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a failed alert toggle retains its checked state and recovers through that same toggle without manual search retry", async () => {
|
||||
savedFixture();
|
||||
mockedApi.patch.mockRejectedValueOnce(new Error("synthetic alerts failure")).mockResolvedValueOnce({ data: { ...savedSearch, isActive: false } } as any);
|
||||
renderPage();
|
||||
const toggle = await screen.findByRole("switch", { name: "Automatic alerts" });
|
||||
fireEvent.click(toggle);
|
||||
expect(await screen.findByText("The alert setting could not be updated. Try again.")).toBeInTheDocument();
|
||||
expect(toggle).toBeChecked();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(toggle).not.toBeChecked());
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.patch).toHaveBeenLastCalledWith("/job-discovery/saved-searches/7", { isActive: false });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("failed saved-search deletion preserves the search and requires another explicit confirmation to recover", async () => {
|
||||
savedFixture();
|
||||
mockedApi.delete.mockRejectedValueOnce(new Error("synthetic delete failure")).mockResolvedValueOnce({ data: null } as any);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
let dialog = await screen.findByRole("dialog", { name: "Delete saved search?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
expect(await screen.findByText("The saved search could not be deleted.")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Backend Oslo")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
dialog = await screen.findByRole("dialog", { name: "Delete saved search?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() => expect(screen.queryByText("Backend Oslo")).not.toBeInTheDocument());
|
||||
expect(mockedApi.delete).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.delete).toHaveBeenLastCalledWith("/job-discovery/saved-searches/7");
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("failed dismissal retains the vacancy and recovers only through that same result's dismiss action", async () => {
|
||||
savedFixture();
|
||||
mockedApi.post.mockResolvedValueOnce(savedRun() as any);
|
||||
mockedApi.patch.mockRejectedValueOnce(new Error("synthetic dismiss failure")).mockResolvedValueOnce({ data: null } as any);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
|
||||
await screen.findByText("Platform Engineer");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(await screen.findByText("The vacancy could not be dismissed.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Platform Engineer")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.patch).toHaveBeenLastCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a held saved-search creation locks its request snapshot and prevents a second start while preserving a failed draft", async () => {
|
||||
const write = deferred<any>();
|
||||
mockedApi.post.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText("No saved searches yet.");
|
||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " developer " } });
|
||||
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Bergen " } });
|
||||
fireEvent.change(screen.getByLabelText("Search name"), { target: { value: " Review my search " } });
|
||||
const save = screen.getByRole("button", { name: "Save search" });
|
||||
fireEvent.click(save);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/job-discovery/saved-searches", { name: "Review my search", query: "developer", location: "Bergen" });
|
||||
expect(save).toBeDisabled();
|
||||
expect(screen.getByLabelText("Role or company")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Municipality")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Search name")).toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { write.reject(new Error("synthetic save failure")); });
|
||||
expect(await screen.findByText("The search could not be saved.")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Role or company")).toHaveValue(" developer ");
|
||||
expect(screen.getByLabelText("Municipality")).toHaveValue(" Bergen ");
|
||||
expect(screen.getByLabelText("Search name")).toHaveValue(" Review my search ");
|
||||
expect(save).toBeEnabled();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a saved-run failure follows the current language without refetching or replaying the request", async () => {
|
||||
savedFixture();
|
||||
mockedApi.post.mockRejectedValueOnce(new Error("synthetic NAV unavailable"));
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
|
||||
expect(await screen.findByText("NAV job discovery is temporarily unavailable.")).toBeInTheDocument();
|
||||
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
expect(screen.getByText("NAVs jobbsøk er midlertidig utilgjengelig.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("NAV job discovery is temporarily unavailable.")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Rolle eller bedrift")).toHaveValue("backend");
|
||||
expect(screen.getByLabelText("Kommune")).toHaveValue("Oslo");
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
@@ -9,6 +9,9 @@ import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
|
||||
type DiscoveredJob = {
|
||||
id: string;
|
||||
@@ -39,9 +42,9 @@ export default function JobDiscoveryPage() {
|
||||
const [location, setLocation] = useState("");
|
||||
const [jobs, setJobs] = useState<DiscoveredJob[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState<TranslationKey | null>(null);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [lastSearch, setLastSearch] = useState({ query: "", location: "" });
|
||||
const [lastSearch, setLastSearch] = useState<{ query: string; location: string; saved?: SavedSearch }>({ query: "", location: "" });
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("updated");
|
||||
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
|
||||
const [savedSearchState, setSavedSearchState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
@@ -50,6 +53,17 @@ export default function JobDiscoveryPage() {
|
||||
const [savingSearch, setSavingSearch] = useState(false);
|
||||
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
||||
const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState<number | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
const removedControlRef = useRef<Element | null>(null);
|
||||
const busy = loading || savingSearch || updatingSavedSearchId !== null || mutating;
|
||||
|
||||
useEffect(() => {
|
||||
const control = removedControlRef.current;
|
||||
removedControlRef.current = null;
|
||||
if (control && !control.isConnected && document.activeElement === document.body) resultsRef.current?.focus();
|
||||
}, [jobs, savedSearches]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -71,12 +85,14 @@ export default function JobDiscoveryPage() {
|
||||
}, [savedSearchLoad]);
|
||||
|
||||
const runSearch = async (nextQuery: string, nextLocation: string) => {
|
||||
setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation });
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setLoading(true); setError(null); setJobs([]); setActiveSavedSearchId(null); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation });
|
||||
try {
|
||||
const response = await api.get<DiscoveredJob[]>("/job-discovery/search", { params: { q: nextQuery || undefined, location: nextLocation || undefined } });
|
||||
setJobs(response.data ?? []);
|
||||
} catch { setError(t("jobDiscoveryUnavailable")); }
|
||||
finally { setLoading(false); }
|
||||
} catch { setError("jobDiscoveryUnavailable"); }
|
||||
finally { pending.current = false; setLoading(false); }
|
||||
};
|
||||
|
||||
const search = (event: FormEvent) => {
|
||||
@@ -85,53 +101,72 @@ export default function JobDiscoveryPage() {
|
||||
};
|
||||
|
||||
const saveSearch = async () => {
|
||||
if (pending.current || savedSearchState !== "loaded") return;
|
||||
pending.current = true;
|
||||
const name = savedName.trim() || query.trim() || location.trim() || t("jobDiscoverySavedDefaultName");
|
||||
setSavingSearch(true); setError("");
|
||||
setSavingSearch(true); setError(null);
|
||||
try {
|
||||
const response = await api.post<SavedSearch>("/job-discovery/saved-searches", { name, query: query.trim(), location: location.trim() });
|
||||
setSavedSearches((current) => [response.data, ...current]);
|
||||
setSavedName("");
|
||||
} catch { setError(t("jobDiscoverySaveSearchFailed")); }
|
||||
finally { setSavingSearch(false); }
|
||||
} catch { setError("jobDiscoverySaveSearchFailed"); }
|
||||
finally { pending.current = false; setSavingSearch(false); }
|
||||
};
|
||||
|
||||
const runSavedSearch = async (saved: SavedSearch) => {
|
||||
setLoading(true); setError(""); setHasSearched(true); setActiveSavedSearchId(saved.id);
|
||||
setQuery(saved.query); setLocation(saved.location); setLastSearch({ query: saved.query, location: saved.location });
|
||||
const runSavedSearch = async (saved: SavedSearch, applyFilters = true) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setLoading(true); setError(null); setJobs([]); setHasSearched(true); setActiveSavedSearchId(saved.id);
|
||||
if (applyFilters) { setQuery(saved.query); setLocation(saved.location); }
|
||||
setLastSearch({ query: saved.query, location: saved.location, saved });
|
||||
try {
|
||||
const response = await api.post<SavedSearchRun>(`/job-discovery/saved-searches/${saved.id}/run`);
|
||||
const visible = response.data.jobs.filter((item) => !item.isDismissed).map((item) => ({ ...item.job, isNew: item.isNew }));
|
||||
setJobs(visible);
|
||||
setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data.search : item));
|
||||
} catch { setError(t("jobDiscoveryUnavailable")); }
|
||||
finally { setLoading(false); }
|
||||
} catch { setError("jobDiscoveryUnavailable"); }
|
||||
finally { pending.current = false; setLoading(false); }
|
||||
};
|
||||
|
||||
const deleteSavedSearch = async (saved: SavedSearch) => {
|
||||
if (!(await confirmAction(t("jobDiscoveryDeleteSavedConfirm", { name: saved.name }), { title: t("jobDiscoveryDeleteSavedTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
const control = document.activeElement;
|
||||
try {
|
||||
if (!(await confirmAction(t("jobDiscoveryDeleteSavedConfirm", { name: saved.name }), { title: t("jobDiscoveryDeleteSavedTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||
setMutating(true);
|
||||
setError(null);
|
||||
await api.delete(`/job-discovery/saved-searches/${saved.id}`);
|
||||
removedControlRef.current = control;
|
||||
setSavedSearches((current) => current.filter((item) => item.id !== saved.id));
|
||||
if (activeSavedSearchId === saved.id) setActiveSavedSearchId(null);
|
||||
} catch { setError(t("jobDiscoveryDeleteSavedFailed")); }
|
||||
} catch { setError("jobDiscoveryDeleteSavedFailed"); }
|
||||
finally { pending.current = false; setMutating(false); }
|
||||
};
|
||||
|
||||
const toggleSavedSearchAlerts = async (saved: SavedSearch) => {
|
||||
setUpdatingSavedSearchId(saved.id); setError("");
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setUpdatingSavedSearchId(saved.id); setError(null);
|
||||
try {
|
||||
const response = await api.patch<SavedSearch>(`/job-discovery/saved-searches/${saved.id}`, { isActive: !saved.isActive });
|
||||
setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data : item));
|
||||
} catch { setError(t("jobDiscoveryUpdateAlertsFailed")); }
|
||||
finally { setUpdatingSavedSearchId(null); }
|
||||
} catch { setError("jobDiscoveryUpdateAlertsFailed"); }
|
||||
finally { pending.current = false; setUpdatingSavedSearchId(null); }
|
||||
};
|
||||
|
||||
const dismissJob = async (jobId: string) => {
|
||||
if (activeSavedSearchId === null) return;
|
||||
if (activeSavedSearchId === null || pending.current) return;
|
||||
pending.current = true;
|
||||
const control = document.activeElement;
|
||||
setMutating(true); setError(null);
|
||||
try {
|
||||
await api.patch(`/job-discovery/saved-searches/${activeSavedSearchId}/results/${encodeURIComponent(jobId)}/dismiss`, { isDismissed: true });
|
||||
removedControlRef.current = control;
|
||||
setJobs((current) => current.filter((item) => item.id !== jobId));
|
||||
setSavedSearches((current) => current.map((item) => item.id === activeSavedSearchId ? { ...item, dismissedCount: item.dismissedCount + 1 } : item));
|
||||
} catch { setError(t("jobDiscoveryDismissFailed")); }
|
||||
} catch { setError("jobDiscoveryDismissFailed"); }
|
||||
finally { pending.current = false; setMutating(false); }
|
||||
};
|
||||
|
||||
const sortedJobs = useMemo(() => [...jobs].sort((left, right) => {
|
||||
@@ -146,42 +181,42 @@ export default function JobDiscoveryPage() {
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null;
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={3} sx={{ minWidth: 0 }}>
|
||||
<Box component="form" onSubmit={search} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr auto" }, gap: 1.5 }}>
|
||||
<TextField label={t("jobDiscoveryRoleCompany")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<TextField label={t("jobDiscoveryMunicipality")} value={location} onChange={(event) => setLocation(event.target.value)} />
|
||||
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>{t("jobDiscoverySearchNav")}</Button>
|
||||
<TextField label={t("jobDiscoveryRoleCompany")} value={query} disabled={busy} onChange={(event) => setQuery(event.target.value)} />
|
||||
<TextField label={t("jobDiscoveryMunicipality")} value={location} disabled={busy} onChange={(event) => setLocation(event.target.value)} />
|
||||
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress aria-hidden="true" size={18} color="inherit" /> : <SearchIcon />} disabled={busy}>{t("jobDiscoverySearchNav")}</Button>
|
||||
</Box>
|
||||
<Card variant="outlined" aria-busy={savedSearchState === "loading"}>
|
||||
<CardContent sx={{ display: "grid", gap: 1.5 }}>
|
||||
<CardContent sx={{ display: "grid", gap: 1.5, p: "var(--app-card-padding)", "&:last-child": { pb: "var(--app-card-padding)" } }}>
|
||||
<Box>
|
||||
<Typography component="h2" variant="h6" sx={{ fontWeight: 800 }}>{t("jobDiscoverySavedSearches")}</Typography>
|
||||
<Typography component="h2" variant="sectionTitle">{t("jobDiscoverySavedSearches")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("jobDiscoverySavedSearchesHelp")}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "minmax(0, 1fr) auto" }, gap: 1 }}>
|
||||
<TextField size="small" label={t("jobDiscoverySavedName")} value={savedName} onChange={(event) => setSavedName(event.target.value)} />
|
||||
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={savingSearch || savedSearchState !== "loaded"}>{t("jobDiscoverySaveSearch")}</Button>
|
||||
<TextField size="small" label={t("jobDiscoverySavedName")} value={savedName} disabled={busy} onChange={(event) => setSavedName(event.target.value)} />
|
||||
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={busy || savedSearchState !== "loaded"}>{t("jobDiscoverySaveSearch")}</Button>
|
||||
</Box>
|
||||
{savedSearchState === "loading" ? <Typography role="status" aria-label={t("jobDiscoverySavedLoading")} variant="body2" color="text.secondary">{t("jobDiscoverySavedLoading")}</Typography> : savedSearchState === "error" ? (
|
||||
<Alert severity="error" variant="outlined" action={<Button color="inherit" onClick={() => setSavedSearchLoad((current) => current + 1)}>{t("retry")}</Button>}>
|
||||
<Alert severity="error" variant="outlined" action={<Button color="inherit" disabled={busy} onClick={() => setSavedSearchLoad((current) => current + 1)}>{t("retry")}</Button>}>
|
||||
{t("jobDiscoverySavedLoadFailed")}
|
||||
</Alert>
|
||||
) : savedSearches.length === 0 ? <Typography variant="body2" color="text.secondary">{t("jobDiscoveryNoSavedSearches")}</Typography> : (
|
||||
<Stack spacing={1}>
|
||||
{savedSearches.map((saved) => (
|
||||
<Box key={saved.id} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: activeSavedSearchId === saved.id ? "primary.main" : "divider", borderRadius: 2 }}>
|
||||
<Box key={saved.id} role="group" aria-label={saved.name} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.5, border: 1, borderColor: activeSavedSearchId === saved.id ? "primary.main" : "divider", borderRadius: 1, minWidth: 0 }}>
|
||||
<Box sx={{ flex: "1 1 220px", minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{saved.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography>
|
||||
<Typography sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{saved.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography>
|
||||
</Box>
|
||||
<Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} />
|
||||
<FormControlLabel
|
||||
sx={{ m: 0 }}
|
||||
control={<Switch size="small" checked={saved.isActive} disabled={updatingSavedSearchId === saved.id} onChange={() => void toggleSavedSearchAlerts(saved)} />}
|
||||
control={<Switch size="small" checked={saved.isActive} disabled={busy} onChange={() => void toggleSavedSearchAlerts(saved)} />}
|
||||
label={<Typography variant="body2">{t("jobDiscoveryAutomaticAlerts")}</Typography>}
|
||||
/>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={loading}>{t("jobDiscoveryRunSaved")}</Button>
|
||||
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={busy}>{t("jobDiscoveryRunSaved")}</Button>
|
||||
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)} disabled={busy}>{t("adminUsersDelete")}</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -189,10 +224,17 @@ export default function JobDiscoveryPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>{t("jobDiscoverySourceHelp")}</Alert>
|
||||
{error ? <Alert severity="error" variant="outlined" sx={{ color: "text.primary" }} action={<Button color="inherit" onClick={() => void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>{t("retry")}</Button>}>{error}</Alert> : null}
|
||||
<Box role="region" aria-label={t("jobDiscoveryResults")} tabIndex={-1} ref={resultsRef} aria-busy={loading} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
{hasSearched ? <PageHeader level="h2" title={t("jobDiscoveryResults")} /> : null}
|
||||
<ViewStateNotice compact loading={loading} title={t(error ?? "jobDiscoveryUnavailable")}
|
||||
error={error ? { kind: "unavailable", message: t(error), retryable: error === "jobDiscoveryUnavailable" } : null}
|
||||
onRetry={() => {
|
||||
if (pending.current) return;
|
||||
resultsRef.current?.focus();
|
||||
void (lastSearch.saved ? runSavedSearch(lastSearch.saved, false) : runSearch(lastSearch.query, lastSearch.location));
|
||||
}} />
|
||||
{!loading && !hasSearched ? <Typography color="text.secondary">{t("jobDiscoveryGuidance")}</Typography> : null}
|
||||
{!loading && hasSearched && !error && jobs.length === 0 ? <Alert severity="info" variant="outlined" sx={{ color: "text.primary" }}>{t("jobDiscoveryNoResults")}</Alert> : null}
|
||||
{loading ? <Typography role="status" color="text.secondary">{t("jobDiscoverySearching")}</Typography> : null}
|
||||
{jobs.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", flexDirection: { xs: "column", sm: "row" } }}>
|
||||
<Typography role="status">{t(jobs.length === 1 ? "jobDiscoveryResultOne" : "jobDiscoveryResultMany", { count: jobs.length })}</Typography>
|
||||
@@ -205,14 +247,14 @@ export default function JobDiscoveryPage() {
|
||||
) : null}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
{sortedJobs.map((job) => (
|
||||
<Card key={job.id} variant="outlined" sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
|
||||
<Card key={job.id} component="article" aria-label={job.title} variant="outlined" sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<CardContent sx={{ p: "var(--app-card-padding)" }}>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}>
|
||||
<Chip label={job.sourceName || job.source.toUpperCase()} size="small" color="primary" variant="outlined" />
|
||||
{job.isNew ? <Chip label={t("jobDiscoveryNewResult")} size="small" color="success" /> : null}
|
||||
</Stack>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>{job.title}</Typography>
|
||||
<Typography color="text.secondary">{[job.company, job.location || t("jobDiscoveryLocationMissing")].filter(Boolean).join(" · ")}</Typography>
|
||||
</Box>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{job.title}</Typography>
|
||||
<Typography color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{[job.company, job.location || t("jobDiscoveryLocationMissing")].filter(Boolean).join(" · ")}</Typography>
|
||||
<Stack spacing={0.25} sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{job.acquisitionType === "searched" ? t("jobDiscoverySearchedListing") : t("jobDiscoverySourceTypeMissing")}
|
||||
@@ -223,14 +265,15 @@ export default function JobDiscoveryPage() {
|
||||
<Typography variant="caption" color="text.secondary">{t("jobDiscoveryWorkArrangementMissing")}</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
<CardActions sx={{ mt: "auto" }}>
|
||||
<CardActions sx={{ mt: "auto", p: "var(--app-card-padding)", pt: 0, flexWrap: "wrap", gap: 1, "& .MuiButton-root": { ml: 0, whiteSpace: "normal", maxWidth: "100%" } }}>
|
||||
<Button href={job.url} target="_blank" rel="noreferrer">{t("jobDiscoveryViewListing")}</Button>
|
||||
<Button startIcon={<AddIcon />} onClick={() => navigate("/jobs?add=" + encodeURIComponent(job.url))}>{t("jobDiscoverySaveTracker")}</Button>
|
||||
{activeSavedSearchId !== null ? <Button color="inherit" onClick={() => void dismissJob(job.id)}>{t("jobDiscoveryDismiss")}</Button> : null}
|
||||
{activeSavedSearchId !== null ? <Button color="inherit" disabled={busy} onClick={() => void dismissJob(job.id)}>{t("jobDiscoveryDismiss")}</Button> : null}
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user