feat(ui): migrate company relationships and save recovery
This commit is contained in:
@@ -45,4 +45,8 @@ Peer review found no product defect. The native geometry test now additionally a
|
||||
|
||||
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 |
|
||||
| P7-05 Discovery | `34a4d6dc6b4bbe546ef01be91e98bd922b99444f` | [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 |
|
||||
|
||||
Observed Discovery normal push2026-10-12 22:49:31.085724UTC proves localHEAD=origin=live34a4d6d and clean worktree, with local/origin/live main27657f3 unchanged. Companies successor records exact receipt and committed baseline; no runtime bytes replaced.
|
||||
|
||||
| P7-05 Companies | Actual hash follows observed normal push | [Results](p7-05/evidence/companies-results.json):30/4,new6+existing2+named4native,backend2,app/browser types/lint/build PASS;20/50images reviewed;new20scans0/91+named20WCAG0/163 qualified. Final hygiene/normal push pending. +7/0frontend689/93 and browser263 derived only; Offers/Reminders next, main unchanged |
|
||||
|
||||
@@ -138,3 +138,7 @@ Public18 top-document all-enabled scans0/12 retain sandbox frame-tested occurren
|
||||
### 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.
|
||||
|
||||
### P7-05 Companies evidence
|
||||
|
||||
[Companies results](p7-05/evidence/companies-results.json) repair demonstrated duplicate/held-save draft ownership, stale relation reads, deep-link hash/state loss, locale refetch, incorrect arbitrary-stage translation, stale response title and BODY focus after dialog exit. Local post-exit fallback preserves valid MUI/moved focus. New20 all-enabled scans0/91 and named20 WCAG0/163 retain complete unresolved overlap/occlusion/modal/contrast/wrapper vectors; finite keyboard/reflow proof does not close M01–M12. Date readbacks compare full calendar seconds without claiming SQLite Kind/DST equivalence. Existing shared cached consumers remain; no inherited debt or whole PRES group is closed.
|
||||
|
||||
@@ -156,3 +156,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### 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.
|
||||
|
||||
### P7-05 Companies finite disposition
|
||||
|
||||
[Companies results](p7-05/evidence/companies-results.json): J01/J08/E08/W11 original nine-field payload/date/source/free-stage semantics, current response title, owner/tenant relation/latest20 projection, protected draft recovery, stale reads, query/hash/state/Back and post-exit focus. Fresh30/4/new6+existing2+named4native/backend2/types/lint/build pass;20/50 images actually reviewed. New20scans0/91 and named20WCAG0/163 remain qualified. No capability/route/API/schema/AI/output change; all57 remain56 PARTIAL/C11 DEFERRED and32 routes. Derived689/93/browser263 are not integrated totals. Cached consumers/manual/release obligations and Offers/Reminders remain; final hygiene/normal push pending.
|
||||
|
||||
@@ -13,7 +13,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [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 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-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 push34a4d6d verified; Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews/qualified91+163incompletes. Companies implemented CONDITIONAL GO, hygiene PASS/normal push pending; 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-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.
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. 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.
|
||||
|
||||
@@ -15,7 +15,7 @@ PRES-J01/J02/J08/J09/W11 and all57 groups remain binding;32 routes stay. No stan
|
||||
|
||||
## 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.
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. 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.
|
||||
|
||||
@@ -26,3 +26,21 @@ All **18 new final PNGs were displayed and reviewed**;13 existing-regression cap
|
||||
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.
|
||||
|
||||
## Companies implemented result
|
||||
|
||||
**Companies implemented scope CONDITIONAL GO; hygiene PASS/normal push pending. Offers and Reminders remain pending.** [Companies entry](evidence/companies-entry.json) verifies the preceding Discovery push34a4d6d and six committed baselines. [Finite results](evidence/companies-results.json) retain exact source/export, failures, contextual triage and all raw outcomes. Fresh affected **30/4**, new ordinary browser **6**, existing company recovery **2**, named-controls **4**, backend **2**, app/browser TypeScript, lint and production build pass. +7 cases/0 suites gives **689/93 frontend derived only**; browser+6 gives **263 derived only**. Latest whole frontend573/89/backend1,033/sidecar49 remain inherited; P7-12 integrated acceptance is pending.
|
||||
|
||||
Shared tokens and semantic mobile articles/headings replace one-off surfaces. Desktop uses a named keyboard-scrollable table with row headers and predictable source/pipeline/date/action columns; its840px canvas scrolls within the container without app-chrome overflow. Existing nine-field PUT, trim/null/date/source semantics, existing routes and arbitrary company pipeline text stay intact. Canonical related application statuses use existing localisation. Loaded relationship title prefers fresh response metadata; selected name remains available during loading.
|
||||
|
||||
A synchronous save guard locks all nine draft fields and Cancel/Escape while one submitted snapshot is pending. Save error keeps the editable draft and recovers through the original Save. Named pending feedback receives focus before disabling fields; an idle empty save notice is omitted to preserve the established live-status contract. Relationship reads use selected identity and request generation; late A success/error and closed reads cannot replace B. Explicit Retry reads B without list reload; permission denial does not offer Retry. Locale changes translate fallback errors/status without fetching Companies again. Edit deep-link replacement removes onlyedit and preserves remaining query/hash/router state and Back.
|
||||
|
||||
Native reproduced BODY focus after dialog exit, including removal of the old invoking action across767/768. A local React effect runs after MUI removes its exiting modal and restores the current company's action only if focus is still body. MUI's valid focus return and deliberately moved focus are retained. Native evidence covers deep-link767→768 and manually opened768→767 drafts, normal relationship Close,320px Edit keyboard wrapping,1024px landscape relationship scrolling/trap, held Save and Retry focus. No shared modal framework change.
|
||||
|
||||
All **20 latest new PNGs were actually displayed and reviewed**;30 existing-regression captures are retained without a claimed review. Eight balanced320/360/390/767/768/1024 landscape/1440/1920 EN/Bokmål/light/dark long-record samples have root overflow0. Table/dialog scrolling is recorded separately. Earlier reviewed long-record images exposed split Source/date/header text; bounded column corrections were rebuilt and these latest images reviewed. New20 all-enabled scans retain **0 violations/91 incompletes**:20 invisible-zero badge,51 overlapping contrast,9 partial occlusion and11 open-modal focus. Existing named20 WCAG-tagged scans retain **0/163**:8 badge,124 overlap,15 partial,4 modal,8 disabled Undo/Redo wrapper labels and4 aria-labelledby shadow IDs. Old company recovery has no axe scan. Complete contexts remain; uncomputed ratio0 is not measured contrast and keyboard proof does not establish human reader/browse-mode acceptance. Sticky-shell landscape painting, full reflow/zoom, physical touch/software keyboard, fonts/photos and M01–M12 remain qualified.
|
||||
|
||||
Real isolated SQLite save/reload, source normalization, latest20 active contacts excluding deleted jobs, relationship projection, exact owner/foreign/anonymous denials and linked Job navigation pass. Two unchanged-binary backend Facts pass; broader filter names do not imply broader matched coverage. Readback comparisons preserve all calendar seconds despite SQLite Kind/Z formatting differences and do not claim UTC/DST qualification. Server details/free text and existing cached useCompanies consumers remain untouched. API fixture creation is distinguished from an absent standalone creation screen.
|
||||
|
||||
Baseline6/2, corrected RED6/7, expanded RED26/4, raw-stage/title RED11/1 and native4/2 plus focused0/1 are frozen. No skipped/expected-failure/retried tests, relaxed original assertions or disabled axe rules. All57 statuses remain56 PARTIAL/C11 DEFERRED;32 routes. Main stays27657f3/production NO-GO, P5-13 withheld and Phase8 separate.
|
||||
|
||||
[Final finite hygiene](evidence/companies-static-checks.json) passed four raw commands at2026-10-13 06:58:01–05UTC,12documents/425links0errors,57groups/32routes; final staged whitespace is checked after staging. Existing named-controls12 dialog scans assert all WCAG-tagged violations,8 full-page scans gate five historical rule IDs; all retained raw scanned violations are independently0. These scopes do not replace the new20 all-enabled Companies scans. Normal push receipt follows observation in the next slice.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Companies",
|
||||
"recordedUtc": "2026-10-12T22:51:43.208402+00:00",
|
||||
"entry": "CONDITIONAL_GO",
|
||||
"previousDiscoveryPush": {
|
||||
"observedUtc": "2026-10-12T22:49:31.085724+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f",
|
||||
"originDevelopment": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f",
|
||||
"liveDevelopment": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f",
|
||||
"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 \u2014 run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed \u2014 run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed \u2014 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 \u2014 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 \u2014 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": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed \u2014 run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed \u2014 run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"baselineSources": [
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/CompaniesTable.tsx",
|
||||
"gitBlobSha256": "f95c5a8aff51cf5d75ae40227c1d07e3277cfdf5eabab3cb6d386cfc3425c72b",
|
||||
"bytes": 16512
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/companies-relationship.test.tsx",
|
||||
"gitBlobSha256": "df2e0f95ea289c38e0aca47e3c043753397ef764d95962084b24b61872388d1b",
|
||||
"bytes": 4471
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/locale-fetch-stability.test.tsx",
|
||||
"gitBlobSha256": "9bb7d13ca31e135d5d5324a9778fb923ab8498494c727193711b8b507d837627",
|
||||
"bytes": 1536
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/hooks/useCompanies.ts",
|
||||
"gitBlobSha256": "a48e2ec2abf50c89b9a983bed93bc3c8b91fc537af6673eb9245cc4bdd5fac10",
|
||||
"bytes": 2344
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/CompaniesController.cs",
|
||||
"gitBlobSha256": "c7be79cc92ad781a7d5fbf52e32dfe24db8915e591fa694a1ffdd6c56861feca",
|
||||
"bytes": 8464
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/CompanyRelationshipTests.cs",
|
||||
"gitBlobSha256": "9f4ebce5eca006ed363a613b5fd4492f47ee60fe5ab942cfd3e9329dae233239",
|
||||
"bytes": 3006
|
||||
}
|
||||
],
|
||||
"baseline": "Affected6/2 frontendPASS; company relation backend2PASS. Wholefrontend573/89 inherited;682/93 and257browser derived only. Existing useCompanies consumers unchanged.",
|
||||
"scope": "Existing nine-field PUT, company edit query/hash/state, owned relationship projections/latest20 contacts, no standalone company-create UI. No backend/schema/production/Phase8/AI change."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-05",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-13T06:58:05.578020+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-13T06:58:01.949800+00:00",
|
||||
"finishedUtc": "2026-10-13T06:58:04.861383+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T065801Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-13T06:58:04.861383+00:00",
|
||||
"finishedUtc": "2026-10-13T06:58:04.963155+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T065801Z/working-whitespace.log",
|
||||
"logSha256Raw": "4011fa7ef9791e39a64f9327c032b5969eac6512908f788160860f86dbac7a49"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-13T06:58:04.963655+00:00",
|
||||
"finishedUtc": "2026-10-13T06:58:05.040920+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T065801Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-13T06:58:05.040920+00:00",
|
||||
"finishedUtc": "2026-10-13T06:58:05.184972+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T065801Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "0e46215e1541a31f97ddf4225979cdea0a745fce18958c41cc8dc307e6b16bfe"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 3,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 12,
|
||||
"relativeLinks": 425,
|
||||
"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."
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Discovery",
|
||||
"recordedUtc": "2026-10-12T22:47:47.672750+00:00",
|
||||
"acceptance": "DISCOVERY_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_PENDING_OTHER_DOMAINS_PENDING",
|
||||
"acceptance": "DISCOVERY_IMPLEMENTED_SCOPE_CONDITIONAL_GO_NORMAL_PUSH_VERIFIED_OTHER_DOMAINS_PENDING",
|
||||
"entryReceipt": {
|
||||
"checkpoint": "P7-05",
|
||||
"entry": "CONDITIONAL GO; approved four domain boundaries pending",
|
||||
@@ -2739,5 +2739,105 @@
|
||||
"bytes": 3166,
|
||||
"hashPolicy": "Exact raw bytes",
|
||||
"qualification": "Four commands0,12docs420links0errors,57groups32routes; final staged recheck follows metadata."
|
||||
},
|
||||
"normalPushReceipt": {
|
||||
"observedUtc": "2026-10-12T22:49:31.085724+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f",
|
||||
"originDevelopment": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f",
|
||||
"liveDevelopment": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f",
|
||||
"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": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f\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": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f\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": "34a4d6dc6b4bbe546ef01be91e98bd922b99444f\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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [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 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-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 push34a4d6d verified. Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews,91+163incompletes qualified. Companies hygiene PASS/normal push pending; Offers/Reminders next.
|
||||
- [ ] 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,551 @@
|
||||
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';
|
||||
import type { Company } from '../src/types';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const companiesUrl = `${apiUrl}/companies`;
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
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[] };
|
||||
type Job = { id: number; jobTitle: string; status: string };
|
||||
type Contact = { id: number; jobApplicationId: number; subject: string; date: string; from: string; channel: string; direction: string };
|
||||
type Relationship = {
|
||||
company: Company;
|
||||
applications: Array<{ id: number; jobTitle: string; status: string; dateApplied: string | null; followUpAt: string | null; messageCount: number; lastContactAt: string | null }>;
|
||||
recentContacts: Array<Contact & { jobTitle: string }>;
|
||||
};
|
||||
type Draft = { name: string; location: string; source: string; recruiterName: string; recruiterEmail: string; recruiterLinkedIn: string; pipelineStage: string; lastContactedAt: string; nextContactAt: string };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const boundary = 'Company creation is an API fixture: the existing Companies page has no standalone create interface. Company list/detail/nine-field PUT, job creation/soft deletion and manual correspondence use the actual isolated owner HTTP API and disposable SQLite. Route-held requests continue to that API when released; explicitly controlled failures establish presentation and recovery only. No provider/model/external service is invoked. Browser request records contain only Companies method/path/query/JSON payload; direct fixture/readback requests are verified separately and no session or CSRF headers are recorded.';
|
||||
|
||||
async function json<T>(result: APIResponse | Response, status = 200): Promise<T> {
|
||||
expect(result.status(), `${result.url()} returns ${status}`).toBe(status);
|
||||
return result.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 } };
|
||||
}
|
||||
|
||||
async function createAccount(admin: APIRequestContext, administrator: Owner) {
|
||||
const email = `p7-companies-${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);
|
||||
return email;
|
||||
}
|
||||
|
||||
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 blockedExternalRequests: string[] = [];
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (!/^\/api\/companies(?:\/|$)/.test(url.pathname)) 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 owner = await login(page.request, await createAccount(admin, administrator));
|
||||
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();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await provide({ owner, requests });
|
||||
} finally {
|
||||
await info.attach('companies-request-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests }), contentType: 'application/json' });
|
||||
await admin.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function createCompany(page: Page, owner: Owner, name: string, location = 'Oslo', source = 'User') {
|
||||
return json<Company>(await page.request.post(companiesUrl, { headers: owner.headers, data: { name, location, source } }), 201);
|
||||
}
|
||||
async function nativeCompany(page: Page, company: Company) { return json<Company>(await page.request.get(`${companiesUrl}/${company.id}`)); }
|
||||
async function nativeList(api: APIRequestContext) { return json<Company[]>(await api.get(companiesUrl)); }
|
||||
async function createJob(page: Page, owner: Owner, company: Company, title: string, status = 'Saved') {
|
||||
return json<Job>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: title, status } }), 201);
|
||||
}
|
||||
async function createContact(page: Page, owner: Owner, job: Job, index: number, subject = `Recorded contact ${String(index).padStart(2, '0')}`) {
|
||||
return json<Contact>(await page.request.post(`${apiUrl}/correspondence`, { headers: owner.headers,
|
||||
data: { jobApplicationId: job.id, from: `Recruiter ${index}`, content: `Owner-recorded correspondence ${index}; no external email delivery.`,
|
||||
subject, channel: 'email', direction: 'inbound', date: new Date(Date.UTC(2026, 10, 1, 10, index)).toISOString() } }), 201);
|
||||
}
|
||||
|
||||
function edit(page: Page, company: Company, language: Sample['language'] = 'en') {
|
||||
return page.getByRole('button', { name: `${translations[language].jobTableEdit}: ${company.name}`, exact: true });
|
||||
}
|
||||
function relationshipAction(page: Page, company: Company, language: Sample['language'] = 'en') {
|
||||
return page.getByRole('button', { name: `${translations[language].companiesRelationship}: ${company.name}`, exact: true });
|
||||
}
|
||||
function editDialog(page: Page, language: Sample['language'] = 'en') {
|
||||
return page.getByRole('dialog', { name: translations[language].companiesEdit, exact: true });
|
||||
}
|
||||
function editRegion(dialog: Locator, language: Sample['language'] = 'en') {
|
||||
return dialog.getByRole('region', { name: translations[language].companiesEdit, exact: true });
|
||||
}
|
||||
function relationshipRegion(dialog: Locator, language: Sample['language'] = 'en') {
|
||||
return dialog.getByRole('region', { name: translations[language].companiesRelationship, exact: true });
|
||||
}
|
||||
function fields(language: Sample['language']) {
|
||||
const t = translations[language];
|
||||
return { name: t.companiesName, location: t.companiesLocation, source: t.companiesSource, recruiterName: t.companiesRecruiterName,
|
||||
recruiterEmail: t.companiesRecruiterEmail, recruiterLinkedIn: t.companiesRecruiterLinkedIn, pipelineStage: t.companiesPipelineStage,
|
||||
lastContactedAt: t.companiesLastContacted, nextContactAt: t.companiesNextContactField };
|
||||
}
|
||||
async function fillDraft(dialog: Locator, draft: Draft, language: Sample['language'] = 'en') {
|
||||
const labels = fields(language);
|
||||
for (const key of Object.keys(labels) as Array<keyof Draft>) await dialog.getByLabel(labels[key], { exact: true }).fill(draft[key]);
|
||||
}
|
||||
async function expectDraft(dialog: Locator, draft: Draft, language: Sample['language'] = 'en') {
|
||||
const labels = fields(language);
|
||||
for (const key of Object.keys(labels) as Array<keyof Draft>) await expect(dialog.getByLabel(labels[key], { exact: true })).toHaveValue(draft[key]);
|
||||
}
|
||||
function snapshot(draft: Draft) {
|
||||
return { ...draft, recruiterName: draft.recruiterName.trim() || null, recruiterEmail: draft.recruiterEmail.trim() || null,
|
||||
recruiterLinkedIn: draft.recruiterLinkedIn.trim() || null, pipelineStage: draft.pipelineStage.trim() || null,
|
||||
lastContactedAt: draft.lastContactedAt || null, nextContactAt: draft.nextContactAt || null,
|
||||
location: draft.location || null, source: draft.source || null };
|
||||
}
|
||||
function draftFor(company: Company, name = `${company.name} reviewed`): Draft {
|
||||
return { name, location: ' Bergen ', source: 'https://www.linkedin.com/', recruiterName: ' Rekrutterer Æ Ø Å ',
|
||||
recruiterEmail: ' reviewed@example.test ', recruiterLinkedIn: ' https://example.test/recruiter ',
|
||||
pipelineStage: ' Interview ', lastContactedAt: '2026-11-21', nextContactAt: '2026-11-23' };
|
||||
}
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
function response(page: Page, method: string, url: string) { return page.waitForResponse(result => result.url() === url && result.request().method() === method); }
|
||||
function count(requests: RequestRecord[], method: string, path: string) { return requests.filter(request => request.method === method && request.path === path).length; }
|
||||
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) {
|
||||
await info.attach(`companies-focus-${state}`, { body: JSON.stringify({ state, 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) };
|
||||
}), qualification: 'Actual isolated Chromium focus; this is not human screen-reader or physical-device acceptance.' }), contentType: 'application/json' });
|
||||
}
|
||||
|
||||
async function enter(page: Page, owner: Owner, sample: Sample = desktop, url = '/companies?source=phase7-companies#preserved') {
|
||||
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(url);
|
||||
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].companies, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function locked(dialog: Locator, language: Sample['language'] = 'en') {
|
||||
const t = translations[language];
|
||||
for (const label of Object.values(fields(language))) await expect(dialog.getByLabel(label, { exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: t.save, exact: true })).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),
|
||||
visibleHorizontalBounds: [...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, role: element.getAttribute('role'), label: element.getAttribute('aria-label'), text: element.textContent?.slice(0, 100), left: rect.left, right: rect.right, width: rect.width,
|
||||
insideNamedScrollRegion: element.closest('[role="region"][aria-label]')?.getAttribute('aria-label') ?? null }; })
|
||||
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 30),
|
||||
scrollRegions: [...document.querySelectorAll<HTMLElement>('[role="region"][aria-label]')].map(element => {
|
||||
const rect = element.getBoundingClientRect(); return { label: element.getAttribute('aria-label'), tabIndex: element.tabIndex, clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, left: rect.left, right: rect.right,
|
||||
overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY };
|
||||
}),
|
||||
}));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-companies-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), boundary,
|
||||
axe: { scope: 'All enabled rules plus target-size; all violations/incomplete vectors retained', violations: axe.violations, incomplete: axe.incomplete,
|
||||
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Finite Chromium states, not a Cartesian width/language/theme/state claim.',
|
||||
'A named bounded desktop company table may legitimately scroll in two dimensions; its geometry is recorded separately from app-root overflow.',
|
||||
'Axe incomplete findings require contextual triage; automated keyboard/focus checks do not establish human screen-reader acceptance.',
|
||||
'Real zoom, physical touch/software keyboard, deployed fonts/photos 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} app chrome reflows`).toBe(0);
|
||||
expect(axe.violations, `${name} complete axe violations`).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-05 Companies] nine-field save persists and owned relationship projects latest20 excluding deleted jobs', async ({ page, session, playwright }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const company = await createCompany(page, session.owner, 'Recorded company relationship');
|
||||
const first = await createJob(page, session.owner, company, 'Owned application A', 'Applied');
|
||||
const second = await createJob(page, session.owner, company, 'Owned application B', 'Interview');
|
||||
const deleted = await createJob(page, session.owner, company, 'Deleted application must remain absent');
|
||||
const contacts: Contact[] = [];
|
||||
for (let index = 0; index < 22; index++) contacts.push(await createContact(page, session.owner, index % 2 === 0 ? first : second, index));
|
||||
const deletedContact = await createContact(page, session.owner, deleted, 90, 'Deleted-job private contact must remain absent');
|
||||
expect((await page.request.delete(`${apiUrl}/jobapplications/${deleted.id}`, { headers: session.owner.headers })).status()).toBe(204);
|
||||
await enter(page, session.owner);
|
||||
await keyboardActivate(page, edit(page, company));
|
||||
const draft = draftFor(company);
|
||||
await fillDraft(editDialog(page), draft);
|
||||
const savedResponse = response(page, 'PUT', `${companiesUrl}/${company.id}`);
|
||||
await keyboardActivate(page, editDialog(page).getByRole('button', { name: translations.en.save, exact: true }));
|
||||
const saved = await json<Company>(await savedResponse);
|
||||
expect(saved).toMatchObject({ id: company.id, name: draft.name, location: 'Bergen', source: 'LinkedIn', recruiterName: 'Rekrutterer Æ Ø Å',
|
||||
recruiterEmail: 'reviewed@example.test', recruiterLinkedIn: 'https://example.test/recruiter', pipelineStage: 'Interview' });
|
||||
expect(saved.lastContactedAt?.slice(0, 10)).toBe(draft.lastContactedAt);
|
||||
expect(saved.nextContactAt?.slice(0, 10)).toBe(draft.nextContactAt);
|
||||
expect(session.requests.filter(request => request.method === 'PUT' && request.path === `/api/companies/${company.id}`)).toEqual([
|
||||
{ method: 'PUT', path: `/api/companies/${company.id}`, query: {}, body: snapshot(draft) },
|
||||
]);
|
||||
expect(await nativeCompany(page, saved)).toEqual(saved);
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(edit(page, saved)).toBeFocused();
|
||||
await page.reload(); await expect(edit(page, saved)).toBeVisible();
|
||||
expect(await nativeList(page.request)).toEqual([saved]);
|
||||
await capture(page, info, desktop, 'saved-reloaded');
|
||||
const loaded = response(page, 'GET', `${companiesUrl}/${company.id}/relationship`);
|
||||
await keyboardActivate(page, relationshipAction(page, saved));
|
||||
const relationship = await json<Relationship>(await loaded);
|
||||
expect(relationship.company).toEqual(saved);
|
||||
expect(relationship.applications.map(row => row.id).sort((a, b) => a - b)).toEqual([first.id, second.id].sort((a, b) => a - b));
|
||||
for (const job of [first, second]) {
|
||||
const expected = contacts.filter(contact => contact.jobApplicationId === job.id);
|
||||
const application = relationship.applications.find(row => row.id === job.id);
|
||||
expect(application).toMatchObject({ jobTitle: job.jobTitle, status: job.status, messageCount: 11 });
|
||||
// SQLite reloads DateTime without its Kind suffix. Check every recorded UTC
|
||||
// calendar second without inventing a retained JSON-Z formatting contract.
|
||||
expect(application?.lastContactAt?.slice(0, 19)).toBe(expected.at(-1)!.date.slice(0, 19));
|
||||
}
|
||||
expect(relationship.recentContacts.map(contact => ({ ...contact, date: contact.date.slice(0, 19) }))).toEqual(contacts.slice(2).reverse().map(contact => ({ id: contact.id, jobApplicationId: contact.jobApplicationId,
|
||||
jobTitle: contact.jobApplicationId === first.id ? first.jobTitle : second.jobTitle, date: contact.date.slice(0, 19), direction: contact.direction,
|
||||
channel: contact.channel, subject: contact.subject, from: contact.from })));
|
||||
const dialog = page.getByRole('dialog', { name: saved.name, exact: true });
|
||||
for (const contact of contacts.slice(2)) await expect(dialog.getByText(contact.subject, { exact: true })).toBeVisible();
|
||||
for (const excluded of [contacts[0].subject, contacts[1].subject, deleted.jobTitle, deletedContact.subject]) await expect(dialog.getByText(excluded, { exact: true })).toHaveCount(0);
|
||||
await expect(dialog.getByText(translations.en.companiesMessageCount.replace('{count}', '11'), { exact: false })).toHaveCount(2);
|
||||
await capture(page, info, desktop, 'latest20-owned-relationship');
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: translations.en.close, exact: true }));
|
||||
await expect(relationshipAction(page, saved)).toBeFocused();
|
||||
|
||||
const admin = await playwright.request.newContext(); const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
|
||||
const denials: Array<{ operation: string; status: number }> = [];
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const other = await login(foreign, await createAccount(admin, administrator));
|
||||
expect(await nativeList(foreign)).toEqual([]);
|
||||
expect(await json<Company[]>(await foreign.get(`${companiesUrl}?ownerUserId=${session.owner.id}&id=${saved.id}`))).toEqual([]);
|
||||
for (const [operation, method, suffix, data] of [
|
||||
['foreign detail', 'GET', `/${saved.id}`, undefined],
|
||||
['foreign relationship', 'GET', `/${saved.id}/relationship`, undefined],
|
||||
['foreign nine-field update', 'PUT', `/${saved.id}?ownerUserId=${session.owner.id}`, { ...snapshot(draft), name: 'Forbidden rename', ownerUserId: session.owner.id }],
|
||||
] as const) {
|
||||
const denied = await foreign.fetch(`${companiesUrl}${suffix}`, { method, headers: other.headers, data });
|
||||
denials.push({ operation, status: denied.status() }); expect(denied.status(), operation).toBe(404);
|
||||
}
|
||||
const foreignCompany = await json<Company>(await foreign.post(companiesUrl, { headers: other.headers,
|
||||
data: { name: 'Other account company', location: 'Tromsø', source: 'User', ownerUserId: session.owner.id } }), 201);
|
||||
expect(await nativeList(foreign)).toEqual([foreignCompany]);
|
||||
expect((await page.request.get(`${companiesUrl}/${foreignCompany.id}/relationship`)).status()).toBe(404);
|
||||
expect((await page.request.put(`${companiesUrl}/${foreignCompany.id}`, { headers: session.owner.headers, data: snapshot(draft) })).status()).toBe(404);
|
||||
expect((await foreign.post(`${apiUrl}/jobapplications`, { headers: other.headers, data: { companyId: saved.id, jobTitle: 'Forbidden association', status: 'Saved' } })).status()).toBe(400);
|
||||
for (const [operation, method, suffix, data] of [
|
||||
['anonymous list', 'GET', '', undefined], ['anonymous detail', 'GET', `/${saved.id}`, undefined],
|
||||
['anonymous relationship', 'GET', `/${saved.id}/relationship`, undefined],
|
||||
['anonymous create', 'POST', '', { name: 'Unauthorized', location: null, source: null }],
|
||||
['anonymous update', 'PUT', `/${saved.id}`, snapshot(draft)],
|
||||
] as const) {
|
||||
const denied = await anonymous.fetch(`${companiesUrl}${suffix}`, { method, data });
|
||||
denials.push({ operation, status: denied.status() }); expect(denied.status(), operation).toBe(401);
|
||||
}
|
||||
expect(await nativeList(page.request)).toEqual([saved]); expect(await nativeCompany(page, saved)).toEqual(saved);
|
||||
expect(await json<Relationship>(await page.request.get(`${companiesUrl}/${saved.id}/relationship`))).toEqual(relationship);
|
||||
await info.attach('companies-native-persistence-and-isolation', { body: JSON.stringify({ company: saved, relationship, deletedJobId: deleted.id,
|
||||
excludedDeletedContactId: deletedContact.id, denials, boundary, qualification: 'Actual native owner/foreign/anonymous HTTP and SQLite readbacks; no intercepted response in these admission/projection checks.' }), contentType: 'application/json' });
|
||||
} finally { await admin.dispose(); await foreign.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-05 Companies] held save locks one snapshot, retains a failed draft and recovers through its original Save', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const sample: Sample = { language: 'nb', theme: 'dark', width: 360, height: 844 }; const t = translations.nb;
|
||||
const company = await createCompany(page, session.owner, 'Recorded held company'); const draft = draftFor(company);
|
||||
const failure = hold(); const recovery = hold(); let writes = 0;
|
||||
await page.route(`${companiesUrl}/${company.id}`, async route => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
writes++;
|
||||
if (writes === 1) { await failure.pending; return route.fulfill({ status: 503, json: {} }); }
|
||||
await recovery.pending; return route.continue();
|
||||
});
|
||||
await enter(page, session.owner, sample); await keyboardActivate(page, edit(page, company, 'nb'));
|
||||
const dialog = editDialog(page, 'nb'); await fillDraft(dialog, draft, 'nb');
|
||||
const failedResponse = response(page, 'PUT', `${companiesUrl}/${company.id}`);
|
||||
try {
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: t.save, exact: true }));
|
||||
await expect.poll(() => writes).toBe(1); await locked(dialog, 'nb');
|
||||
await expect(editRegion(dialog, 'nb')).toBeFocused(); await recordFocus(page, info, 'held-save-stable-region');
|
||||
await expect(dialog.getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('status')).toHaveText(t.loading); await expect(dialog.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); await expect(dialog).toBeVisible(); expect(writes).toBe(1);
|
||||
expect(await nativeCompany(page, company)).toEqual(company);
|
||||
await capture(page, info, sample, 'held-save');
|
||||
} finally { failure.release(); }
|
||||
expect((await failedResponse).status()).toBe(503);
|
||||
await expect(dialog.getByRole('alert')).toHaveText(t.companiesUpdateFailed); await expectDraft(dialog, draft, 'nb');
|
||||
await expect(dialog.getByRole('button', { name: t.save, exact: true })).toBeEnabled();
|
||||
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeEnabled();
|
||||
await expect(dialog.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
expect(await nativeCompany(page, company)).toEqual(company); await capture(page, info, sample, 'failed-save-durable-draft');
|
||||
const recoveredResponse = response(page, 'PUT', `${companiesUrl}/${company.id}`);
|
||||
try {
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: t.save, exact: true }));
|
||||
await expect.poll(() => writes).toBe(2); await locked(dialog, 'nb'); await expect(editRegion(dialog, 'nb')).toBeFocused();
|
||||
await page.keyboard.press('Enter'); expect(writes).toBe(2); await capture(page, info, sample, 'recovery-save-held');
|
||||
} finally { recovery.release(); }
|
||||
const saved = await json<Company>(await recoveredResponse); await expect(dialog).toHaveCount(0);
|
||||
await expect(edit(page, saved, 'nb')).toBeFocused(); await recordFocus(page, info, 'recovered-save-invoker');
|
||||
expect(session.requests.filter(request => request.method === 'PUT')).toEqual([1, 2].map(() => ({ method: 'PUT', path: `/api/companies/${company.id}`, query: {}, body: snapshot(draft) })));
|
||||
expect(await nativeCompany(page, saved)).toEqual(saved); await page.reload(); await expect(edit(page, saved, 'nb')).toBeVisible();
|
||||
await keyboardActivate(page, edit(page, saved, 'nb')); await page.keyboard.press('Escape');
|
||||
await expect(editDialog(page, 'nb')).toHaveCount(0); await expect(edit(page, saved, 'nb')).toBeFocused(); expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
test('[P7-05 Companies] late relationship A cannot replace B and Retry reads the same company with stable focus', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000);
|
||||
const t = translations.en; const sample: Sample = { ...desktop, width: 390, height: 844, theme: 'dark' };
|
||||
const a = await createCompany(page, session.owner, 'Recorded relationship A'); const b = await createCompany(page, session.owner, 'Recorded relationship B');
|
||||
const aJob = await createJob(page, session.owner, a, 'A application remains separate'); const bJob = await createJob(page, session.owner, b, 'B current application', 'Interview');
|
||||
const aUrl = `${companiesUrl}/${a.id}/relationship`; const bUrl = `${companiesUrl}/${b.id}/relationship`;
|
||||
await enter(page, session.owner, sample); await expect(edit(page, a)).toBeVisible(); const listReads = count(session.requests, 'GET', '/api/companies');
|
||||
for (const outcome of ['success', 'error'] as const) {
|
||||
const gate = hold(); let starts = 0;
|
||||
await page.route(aUrl, async route => { starts++; await gate.pending;
|
||||
return outcome === 'success' ? route.continue() : route.fulfill({ status: 503, json: {} }); });
|
||||
const lateResponse = response(page, 'GET', aUrl);
|
||||
try {
|
||||
await keyboardActivate(page, relationshipAction(page, a));
|
||||
const pending = page.getByRole('dialog', { name: a.name, exact: true });
|
||||
await expect.poll(() => starts).toBe(1); await expect(pending.getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
|
||||
await expect(pending.getByText(aJob.jobTitle, { exact: true })).toHaveCount(0);
|
||||
await keyboardActivate(page, pending.getByRole('button', { name: t.close, exact: true }));
|
||||
await expect(pending).toHaveCount(0); await expect(relationshipAction(page, a)).toBeFocused();
|
||||
await keyboardActivate(page, relationshipAction(page, b));
|
||||
const current = page.getByRole('dialog', { name: b.name, exact: true });
|
||||
await expect(current.getByText(bJob.jobTitle, { exact: true })).toBeVisible();
|
||||
gate.release(); expect((await lateResponse).status()).toBe(outcome === 'success' ? 200 : 503);
|
||||
await expect(current).toBeVisible(); await expect(current.getByText(bJob.jobTitle, { exact: true })).toBeVisible();
|
||||
await expect(current.getByText(aJob.jobTitle, { exact: true })).toHaveCount(0); await expect(current.getByRole('alert')).toHaveCount(0);
|
||||
await keyboardActivate(page, current.getByRole('button', { name: t.close, exact: true }));
|
||||
await expect(current).toHaveCount(0); await expect(relationshipAction(page, b)).toBeFocused();
|
||||
} finally { gate.release(); await page.unroute(aUrl); }
|
||||
}
|
||||
const retryGate = hold(); let attempts = 0; let forbidden = false;
|
||||
await page.route(bUrl, async route => {
|
||||
attempts++;
|
||||
if (forbidden) return route.fulfill({ status: 403, json: {} });
|
||||
if (attempts === 1) return route.fulfill({ status: 503, json: {} });
|
||||
await retryGate.pending; return route.continue();
|
||||
});
|
||||
await keyboardActivate(page, relationshipAction(page, b)); const dialog = page.getByRole('dialog', { name: b.name, exact: true });
|
||||
await expect(dialog.getByRole('alert')).toContainText(t.companiesRelationshipLoadFailed); await expect(dialog.getByText(bJob.jobTitle, { exact: true })).toHaveCount(0);
|
||||
const retryResponse = response(page, 'GET', bUrl);
|
||||
try {
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: t.retry, exact: true }));
|
||||
await expect.poll(() => attempts).toBe(2); await expect(relationshipRegion(dialog)).toBeFocused();
|
||||
await recordFocus(page, info, 'relationship-retry-held');
|
||||
await expect(dialog.getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('status')).toHaveAttribute('aria-live', 'polite'); await expect(dialog.getByRole('status')).toHaveText(t.loading);
|
||||
await page.keyboard.press('Enter'); expect(attempts).toBe(2); await capture(page, info, sample, 'relationship-retry-held');
|
||||
} finally { retryGate.release(); }
|
||||
expect((await retryResponse).status()).toBe(200); await expect(dialog.getByText(bJob.jobTitle, { exact: true })).toBeVisible();
|
||||
await expect(relationshipRegion(dialog)).toBeFocused(); await expect(dialog.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
expect(count(session.requests, 'GET', '/api/companies')).toBe(listReads);
|
||||
expect(count(session.requests, 'GET', `/api/companies/${a.id}/relationship`)).toBe(2);
|
||||
expect(count(session.requests, 'GET', `/api/companies/${b.id}/relationship`)).toBe(4);
|
||||
await capture(page, info, sample, 'relationship-recovered');
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: t.close, exact: true })); await expect(dialog).toHaveCount(0);
|
||||
forbidden = true; await keyboardActivate(page, relationshipAction(page, b));
|
||||
const denied = page.getByRole('dialog', { name: b.name, exact: true });
|
||||
await expect(denied.getByRole('alert')).toContainText(t.companiesRelationshipLoadFailed);
|
||||
await expect(denied.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
await expect(denied.getByText(bJob.jobTitle, { exact: true })).toHaveCount(0);
|
||||
await info.attach('companies-relationship-read-scope', { body: JSON.stringify({ aId: a.id, bId: b.id, listReads, attempts,
|
||||
qualification: 'A success and B Retry continue to native owner GET; late A503/B503/B403 are explicit presentation fixtures. Real authorization denials are exercised separately.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Companies] edit deep link preserves query hash state and Back; open drafts survive767/768 transition', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const sample: Sample = { language: 'nb', theme: 'light', width: 767, height: 900 }; const t = translations.nb;
|
||||
const company = await createCompany(page, session.owner, 'Recorded deep-link company');
|
||||
const job = await createJob(page, session.owner, company, 'Recorded relationship navigation');
|
||||
await enter(page, session.owner, sample); await expect(edit(page, company, 'nb')).toBeVisible();
|
||||
await page.goto('/jobs?previous=companies-context#prior'); await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await page.addInitScript(() => {
|
||||
if (location.pathname === '/companies' && new URLSearchParams(location.search).has('edit')) {
|
||||
window.history.replaceState({ ...window.history.state, usr: { source: 'relationship-history', preservedMarker: 'p7-companies' } }, '');
|
||||
}
|
||||
});
|
||||
await page.goto(`/companies?edit=${company.id}&source=relationships&filter=active#preserved-context`);
|
||||
const dialog = editDialog(page, 'nb'); await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/companies\?source=relationships&filter=active#preserved-context$/);
|
||||
expect(await page.evaluate(() => window.history.state.usr)).toEqual({ source: 'relationship-history', preservedMarker: 'p7-companies' });
|
||||
const reads = count(session.requests, 'GET', '/api/companies');
|
||||
const name = dialog.getByLabel(t.companiesName, { exact: true }); const recruiter = dialog.getByLabel(t.companiesRecruiterName, { exact: true });
|
||||
await name.fill('Unsaved responsive company draft Æ Ø Å'); await recruiter.fill('Unsaved responsive recruiter');
|
||||
await recruiter.focus(); await expect(recruiter).toBeFocused();
|
||||
expect(await dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(true);
|
||||
await page.setViewportSize({ width: 768, height: 900 });
|
||||
await expect.poll(() => dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(false);
|
||||
await expect(name).toHaveValue('Unsaved responsive company draft Æ Ø Å'); await expect(recruiter).toHaveValue('Unsaved responsive recruiter');
|
||||
await expect(recruiter).toBeFocused(); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads);
|
||||
await capture(page, info, { ...sample, width: 768 }, 'open-draft-breakpoint-transition');
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: t.cancel, exact: true })); await expect(dialog).toHaveCount(0);
|
||||
await recordFocus(page, info, 'responsive-cancel-invoker'); await expect(edit(page, company, 'nb')).toBeFocused();
|
||||
expect(count(session.requests, 'PUT', `/api/companies/${company.id}`)).toBe(0); expect(await nativeCompany(page, company)).toEqual(company);
|
||||
// This second opening has an actual focused desktop invoker. Its row is
|
||||
// replaced by the mobile article while the same edit draft stays mounted.
|
||||
await keyboardActivate(page, edit(page, company, 'nb')); await expect(dialog).toBeVisible();
|
||||
await name.fill('Manually opened responsive draft Æ Ø Å'); await recruiter.fill('Manually opened responsive recruiter');
|
||||
await recruiter.focus(); await expect(recruiter).toBeFocused();
|
||||
expect(await dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(false);
|
||||
await page.setViewportSize({ width: 767, height: 900 });
|
||||
await expect.poll(() => dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(true);
|
||||
await expect(name).toHaveValue('Manually opened responsive draft Æ Ø Å'); await expect(recruiter).toHaveValue('Manually opened responsive recruiter');
|
||||
await expect(recruiter).toBeFocused(); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads);
|
||||
await capture(page, info, sample, 'manual-open-draft-breakpoint-transition');
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: t.cancel, exact: true })); await expect(dialog).toHaveCount(0);
|
||||
await recordFocus(page, info, 'manual-responsive-cancel-invoker'); await expect(edit(page, company, 'nb')).toBeFocused();
|
||||
expect(count(session.requests, 'PUT', `/api/companies/${company.id}`)).toBe(0); expect(await nativeCompany(page, company)).toEqual(company);
|
||||
await keyboardActivate(page, relationshipAction(page, company, 'nb'));
|
||||
const historyDialog = page.getByRole('dialog', { name: company.name, exact: true }); await expect(historyDialog.getByText(job.jobTitle, { exact: true })).toBeVisible();
|
||||
await keyboardActivate(page, historyDialog.getByRole('button', { name: t.notificationsOpen, exact: true }));
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
|
||||
await page.goBack(); await expect(edit(page, company, 'nb')).toBeVisible(); await expect(editDialog(page, 'nb')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/companies\?source=relationships&filter=active#preserved-context$/);
|
||||
expect(await page.evaluate(() => window.history.state.usr)).toEqual({ source: 'relationship-history', preservedMarker: 'p7-companies' });
|
||||
await page.goBack(); await expect(page).toHaveURL(/\/jobs\?previous=companies-context#prior$/);
|
||||
await info.attach('companies-route-context', { body: JSON.stringify({ source: 'BrowserRouter history.state.usr set before the deep-link mount to represent an existing router entry.',
|
||||
companyId: company.id, jobId: job.id, qualification: 'Real browser edit-parameter replacement, retained query/hash/state and relationship-to-job Back journey; no new navigation is introduced.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Companies] live locale changes translate failures and relationship status without refetching or losing drafts', async ({ page, context, session }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const en = translations.en; const nb = translations.nb;
|
||||
const company = await createCompany(page, session.owner, 'Recorded language-independent company');
|
||||
const job = await createJob(page, session.owner, company, 'Recorded bilingual relationship', 'Interview');
|
||||
await enter(page, session.owner); await expect(edit(page, company)).toBeVisible();
|
||||
const reads = count(session.requests, 'GET', '/api/companies');
|
||||
const preference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
|
||||
await keyboardActivate(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true }));
|
||||
expect((await preference).status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
await expect(edit(page, company, 'nb')).toBeVisible(); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads);
|
||||
await keyboardActivate(page, relationshipAction(page, company, 'nb'));
|
||||
const relationship = page.getByRole('dialog', { name: company.name, exact: true });
|
||||
await expect(relationship.getByText(job.jobTitle, { exact: true })).toBeVisible();
|
||||
await expect(relationship.getByText(new RegExp(`^${nb.statusInterview} · `))).toBeVisible();
|
||||
await keyboardActivate(page, relationship.getByRole('button', { name: nb.close, exact: true })); await expect(relationship).toHaveCount(0);
|
||||
await page.route(`${companiesUrl}/${company.id}`, route => route.request().method() === 'PUT' ? route.fulfill({ status: 503, json: {} }) : route.continue());
|
||||
await keyboardActivate(page, edit(page, company, 'nb')); const dialog = editDialog(page, 'nb');
|
||||
const draft = draftFor(company, 'Retained locale draft Æ Ø Å'); await fillDraft(dialog, draft, 'nb');
|
||||
await keyboardActivate(page, dialog.getByRole('button', { name: nb.save, exact: true })); await expect(dialog.getByRole('alert')).toHaveText(nb.companiesUpdateFailed);
|
||||
const before = [...session.requests];
|
||||
const otherTab = await context.newPage();
|
||||
try {
|
||||
await otherTab.goto('/companies'); await expect(otherTab.getByRole('heading', { level: 1, name: nb.companies, exact: true })).toBeVisible();
|
||||
await otherTab.evaluate(() => localStorage.setItem('uiLanguage', 'en'));
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
const translated = editDialog(page);
|
||||
await expect(translated.getByRole('alert')).toHaveText(en.companiesUpdateFailed); await expectDraft(translated, draft);
|
||||
await expect(translated.getByRole('button', { name: en.save, exact: true })).toBeEnabled();
|
||||
expect(session.requests).toEqual(before); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads);
|
||||
await capture(page, info, desktop, 'live-cross-tab-locale-retained-failure');
|
||||
await keyboardActivate(page, translated.getByRole('button', { name: en.cancel, exact: true })); await expect(translated).toHaveCount(0);
|
||||
await expect(edit(page, company)).toBeFocused(); expect(await nativeCompany(page, company)).toEqual(company);
|
||||
} finally { await otherTab.close(); }
|
||||
await info.attach('companies-locale-boundary', { body: JSON.stringify({ initialHeaderChange: 'Actual named Norsk header control and native language-preference204.',
|
||||
modalChange: 'Actual same-origin second browser tab changes localStorage, producing a storage event while the primary modal stays mounted.',
|
||||
recordedCompaniesReads: reads, qualification: 'The primary page request log proves no Companies refetch or duplicate PUT during its locale transition. The second tab has its own independent initial read; no hidden modal-background control was forced.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Companies] eight finite language/theme widths reflow with named table scrolling and keyboard dialogs', async ({ page, session }, info) => {
|
||||
test.setTimeout(270_000);
|
||||
const company = await createCompany(page, session.owner, 'Samfunnskritiske systemer og rekruttering for nordnorske virksomheter Æ Ø Å — langsiktig samarbeid',
|
||||
'Tromsø og internasjonalt samarbeid med flere steder i Nord-Norge');
|
||||
const draft: Draft = { name: company.name, location: company.location!, source: 'LinkedIn',
|
||||
recruiterName: 'Rekrutterer med et langt meningsfullt navn Æ Ø Å', recruiterEmail: 'lang-rekruttererkontakt-for-virksomheten@example.test',
|
||||
recruiterLinkedIn: 'https://example.test/recruiter/long-recorded-contact-context', pipelineStage: 'Oppfølging av langsiktig relasjon',
|
||||
lastContactedAt: '2026-11-01', nextContactAt: '2026-12-01' };
|
||||
const saved = await json<Company>(await page.request.put(`${companiesUrl}/${company.id}`, { headers: session.owner.headers, data: snapshot(draft) }));
|
||||
const job = await createJob(page, session.owner, saved, 'Senior utvikler for samfunnskritiske systemer med ansvar for kvalitet Æ Ø Å', 'Interview');
|
||||
await createContact(page, session.owner, job, 1, 'Oppfølging av en langsiktig og meningsfull søknadsprosess med et langt emne Æ Ø Å');
|
||||
const samples: Sample[] = [
|
||||
{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 },
|
||||
{ language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 767, height: 900 },
|
||||
{ language: 'en', theme: 'dark', width: 768, height: 1024 }, { language: 'nb', theme: 'light', width: 1024, height: 320 },
|
||||
{ language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 },
|
||||
];
|
||||
for (const sample of samples) {
|
||||
const t = translations[sample.language]; await enter(page, session.owner, sample); await expect(edit(page, saved, sample.language)).toBeVisible();
|
||||
if (sample.width < 768) {
|
||||
const article = page.getByRole('article', { name: saved.name, exact: true }); await expect(article).toBeVisible();
|
||||
await expect(article.getByRole('heading', { level: 2, name: saved.name, exact: true })).toBeVisible(); await expect(page.getByRole('table', { name: t.companies, exact: true })).toHaveCount(0);
|
||||
} else {
|
||||
const table = page.getByRole('table', { name: t.companies, exact: true }); const region = page.getByRole('region', { name: t.companies, exact: true });
|
||||
await expect(table).toBeVisible(); await expect(region).toHaveAttribute('tabindex', '0');
|
||||
const geometry = await region.evaluate(element => ({ width: element.clientWidth, scrollWidth: element.scrollWidth, left: element.getBoundingClientRect().left, right: element.getBoundingClientRect().right, viewport: innerWidth }));
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(0); expect(geometry.right).toBeLessThanOrEqual(geometry.viewport);
|
||||
await region.focus(); await expect(region).toBeFocused();
|
||||
if (geometry.scrollWidth > geometry.width + 1) {
|
||||
await page.keyboard.press('ArrowRight'); await expect.poll(() => region.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
|
||||
await region.evaluate(element => { element.scrollLeft = 0; });
|
||||
}
|
||||
}
|
||||
await capture(page, info, sample, 'loaded-finite-sample');
|
||||
if (sample.width === 320) {
|
||||
await keyboardActivate(page, edit(page, saved, sample.language)); const dialog = editDialog(page, sample.language);
|
||||
const save = dialog.getByRole('button', { name: t.save, exact: true }); const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
|
||||
await save.focus(); await page.keyboard.press('Tab'); await expect(dialog.getByLabel(t.companiesName, { exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab'); await expect(save).toBeFocused();
|
||||
await capture(page, info, sample, 'mobile-edit-keyboard-trap');
|
||||
await keyboardActivate(page, cancel); await expect(dialog).toHaveCount(0); await expect(edit(page, saved, sample.language)).toBeFocused();
|
||||
}
|
||||
if (sample.width === 1024) {
|
||||
await keyboardActivate(page, relationshipAction(page, saved, sample.language)); const dialog = page.getByRole('dialog', { name: saved.name, exact: true });
|
||||
await expect(dialog.getByText(job.jobTitle, { exact: true })).toBeVisible(); const region = relationshipRegion(dialog, sample.language);
|
||||
const scroll = await region.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight }));
|
||||
expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight);
|
||||
await region.focus(); await page.keyboard.press('PageDown'); await expect.poll(() => region.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
|
||||
const close = dialog.getByRole('button', { name: t.close, exact: true }); await close.focus(); await page.keyboard.press('Tab');
|
||||
await expect(dialog.getByRole('button', { name: t.notificationsOpen, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab'); await expect(close).toBeFocused();
|
||||
await capture(page, info, sample, 'landscape-relationship-scroll-trap');
|
||||
await keyboardActivate(page, close); await expect(dialog).toHaveCount(0);
|
||||
await recordFocus(page, info, 'landscape-relationship-close');
|
||||
await expect(relationshipAction(page, saved, sample.language)).toBeFocused();
|
||||
}
|
||||
expect(await nativeCompany(page, saved)).toEqual(saved);
|
||||
}
|
||||
expect(count(session.requests, 'PUT', `/api/companies/${saved.id}`)).toBe(0);
|
||||
await info.attach('companies-finite-samples', { body: JSON.stringify({ samples, boundary,
|
||||
qualification: 'Exactly eight balanced English/Bokmål and light/dark samples including767/768 and1024×320 landscape, plus two keyboard modal states. Named table geometry/keyboard horizontal scrolling is measured when it overflows. No Cartesian, real zoom, human AT or physical-device claim.' }), contentType: 'application/json' });
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import CompaniesTable from "./components/CompaniesTable";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
test("opens one company relationship view with applications and recent contact", async () => {
|
||||
const company = { id: 3, name: "Acme", recruiterName: "Rita", recruiterEmail: "rita@example.test" };
|
||||
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("/relationship") ? {
|
||||
company,
|
||||
company: { ...company, name: "Acme refreshed" },
|
||||
applications: [{ id: 9, jobTitle: "Backend Developer", status: "Interview", messageCount: 1 }],
|
||||
recentContacts: [{ id: 12, jobApplicationId: 9, jobTitle: "Backend Developer", date: "2026-08-10T10:00:00Z", channel: "email", subject: "Interview", from: "Rita" }],
|
||||
} : [company] } as any));
|
||||
@@ -34,16 +34,18 @@ test("opens one company relationship view with applications and recent contact",
|
||||
expect(screen.getAllByText("Backend Developer").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Interview · 1 messages/)).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/companies/3/relationship");
|
||||
expect(await screen.findByRole("dialog", { name: "Acme refreshed" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(["en", "nb"])("company contact dates follow the %s UI locale", async (language) => {
|
||||
window.localStorage.setItem("uiLanguage", language);
|
||||
const nextContactAt = "2026-11-23T12:00:00Z";
|
||||
mockedApi.get.mockResolvedValue({ data: [{ id: 3, name: "Acme", nextContactAt }] });
|
||||
mockedApi.get.mockResolvedValue({ data: [{ id: 3, name: "Acme", nextContactAt, pipelineStage: "Interview" }] });
|
||||
render(<I18nProvider><ToastProvider><MemoryRouter><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
|
||||
const expected = new Date(nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en");
|
||||
await screen.findByRole("button", { name: `${language === "nb" ? "Rediger" : "Edit"}: Acme` });
|
||||
expect(screen.getByRole("row", { name: /Acme/ })).toHaveTextContent(expected);
|
||||
expect(screen.getByRole("row", { name: /Acme/ })).toHaveTextContent("Interview");
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -72,3 +74,158 @@ test.each([
|
||||
expect(await screen.findByRole("button", { name: `${language === "en" ? "Edit" : "Rediger"}: Acme updated` })).toBeVisible();
|
||||
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/companies/3", expect.objectContaining({ name: "Acme updated" }));
|
||||
});
|
||||
|
||||
const companyA = { id: 3, name: "Acme", location: "Oslo", source: "User", recruiterName: "Rita", recruiterEmail: "rita@example.test" };
|
||||
const companyB = { id: 4, name: "Northstar", location: "Bergen", source: "User" };
|
||||
|
||||
function relationship(company: { id: number; name: string } = companyA) {
|
||||
return { data: { company, applications: [{ id: company.id + 10, jobTitle: `${company.name} role`, status: "Interview", messageCount: 1 }], recentContacts: [] } };
|
||||
}
|
||||
|
||||
function RouteContext() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return <><output aria-label="Route context">{JSON.stringify({ pathname: location.pathname, search: location.search, hash: location.hash, state: location.state })}</output><button onClick={() => navigate(-1)}>Previous route</button></>;
|
||||
}
|
||||
|
||||
function renderPage(initialEntries: any[] = ["/companies"]) {
|
||||
return render(<I18nProvider><ToastProvider><MemoryRouter initialEntries={initialEntries}><RouteContext /><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
|
||||
}
|
||||
|
||||
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 held company save locks all nine fields and cancellation, submits one exact snapshot, and retains a failed draft", async () => {
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockResolvedValue({ data: [companyA] } as any);
|
||||
mockedApi.put.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Edit: Acme" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Edit Company" });
|
||||
const draft = {
|
||||
Name: "Acme updated", Location: "Bergen", Source: "User", "Pipeline stage": " Interview ",
|
||||
"Recruiter name": " Updated recruiter ", "Recruiter email": " updated@example.test ",
|
||||
"Recruiter LinkedIn": " https://example.test/recruiter ", "Last contacted": "2026-11-21", "Next contact": "2026-11-23",
|
||||
};
|
||||
Object.entries(draft).forEach(([label, value]) => fireEvent.change(within(dialog).getByLabelText(label), { target: { value } }));
|
||||
const save = within(dialog).getByRole("button", { name: "Save" });
|
||||
fireEvent.click(save);
|
||||
expect(mockedApi.put).toHaveBeenCalledWith("/companies/3", {
|
||||
name: "Acme updated", location: "Bergen", source: "User", recruiterName: "Updated recruiter",
|
||||
recruiterEmail: "updated@example.test", recruiterLinkedIn: "https://example.test/recruiter",
|
||||
pipelineStage: "Interview", lastContactedAt: "2026-11-21", nextContactAt: "2026-11-23",
|
||||
});
|
||||
expect(save).toBeDisabled();
|
||||
Object.keys(draft).forEach((label) => expect(within(dialog).getByLabelText(label)).toBeDisabled());
|
||||
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
expect(dialog).toBeVisible();
|
||||
await act(async () => { write.reject(new Error("synthetic save failure")); });
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Failed to update company.");
|
||||
Object.entries(draft).forEach(([label, value]) => expect(within(dialog).getByLabelText(label)).toHaveValue(value));
|
||||
expect(save).toBeEnabled();
|
||||
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeEnabled();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a locale change translates an existing company-save failure without refetching or replacing its draft", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [companyA] } as any);
|
||||
mockedApi.put.mockRejectedValueOnce(new Error("synthetic save failure"));
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Edit: Acme" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Name" }), { target: { value: "Acme unsaved" } });
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Recruiter name" }), { target: { value: "Unsaved recruiter" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Failed to update company.");
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("Kunne ikke oppdatere selskap.");
|
||||
expect(within(dialog).getByRole("textbox", { name: "Navn" })).toHaveValue("Acme unsaved");
|
||||
expect(within(dialog).getByRole("textbox", { name: "Navn på rekrutterer" })).toHaveValue("Unsaved recruiter");
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("retrying a failed relationship read loads the same company without reloading the company list", async () => {
|
||||
const retry = deferred<any>();
|
||||
let reads = 0;
|
||||
mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship"
|
||||
? ++reads === 1 ? Promise.reject(new Error("synthetic relationship failure")) : retry.promise
|
||||
: Promise.resolve({ data: [companyA] }) as any);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Could not load the relationship history.");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retry" }));
|
||||
expect(reads).toBe(2);
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/companies")).toHaveLength(1);
|
||||
expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument();
|
||||
await act(async () => { retry.resolve(relationship()); });
|
||||
expect(await within(dialog).findByText("Acme role")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(reads).toBe(2);
|
||||
});
|
||||
|
||||
test.each(["success", "failure"])("a late %s for relationship A cannot replace the opened relationship B", async (outcome) => {
|
||||
const previous = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? previous.promise
|
||||
: Promise.resolve(url === "/companies/4/relationship" ? relationship(companyB) : { data: [companyA, companyB] }) as any);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Relationship history: Northstar" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(await within(dialog).findByText("Northstar role")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
if (outcome === "success") previous.resolve(relationship());
|
||||
else previous.reject(new Error("late A relationship failure"));
|
||||
});
|
||||
expect(within(dialog).getByText("Northstar role")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => String(url).endsWith("/relationship"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a relationship response received after closing stays closed and does not populate another held relationship", async () => {
|
||||
const previous = deferred<any>();
|
||||
const next = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? previous.promise
|
||||
: url === "/companies/4/relationship" ? next.promise : Promise.resolve({ data: [companyA, companyB] }) as any);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
await act(async () => { previous.resolve(relationship()); });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Relationship history: Northstar" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Northstar role")).not.toBeInTheDocument();
|
||||
await act(async () => { next.resolve(relationship(companyB)); });
|
||||
expect(await within(dialog).findByText("Northstar role")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the company edit deep link removes only edit while preserving query, hash, state and replace navigation", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [companyA] } as any);
|
||||
renderPage([
|
||||
{ pathname: "/jobs/9", search: "?keep=true", hash: "#source", state: { previous: true } },
|
||||
{ pathname: "/companies", search: "?edit=3&return=jobs%2F9", hash: "#activity", state: { returnTo: "/jobs/9" } },
|
||||
]);
|
||||
const route = screen.getByLabelText("Route context");
|
||||
expect(await screen.findByRole("dialog", { name: "Edit Company" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(JSON.parse(route.textContent!).search).toBe("?return=jobs%2F9"));
|
||||
expect(JSON.parse(route.textContent!)).toEqual({ pathname: "/companies", search: "?return=jobs%2F9", hash: "#activity", state: { returnTo: "/jobs/9" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous route" }));
|
||||
expect(JSON.parse(route.textContent!)).toEqual({ pathname: "/jobs/9", search: "?keep=true", hash: "#source", state: { previous: true } });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -30,7 +30,8 @@ import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
|
||||
import { statusLabel } from "../pipeline";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
@@ -39,12 +40,22 @@ export default function CompaniesTable() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editError, setEditError] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Company | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savePending = useRef(false);
|
||||
const editContentRef = useRef<HTMLDivElement>(null);
|
||||
const editCompanyIdRef = useRef<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [focusReturn, setFocusReturn] = useState<{ action: "edit" | "relationship"; id?: number | null } | null>(null);
|
||||
const [relationshipOpen, setRelationshipOpen] = useState(false);
|
||||
const [relationshipCompany, setRelationshipCompany] = useState<Company | null>(null);
|
||||
const [relationshipLoading, setRelationshipLoading] = useState(false);
|
||||
const [relationshipError, setRelationshipError] = useState("");
|
||||
const [relationshipError, setRelationshipError] = useState<ViewResourceError | null>(null);
|
||||
const [relationship, setRelationship] = useState<CompanyRelationship | null>(null);
|
||||
const relationshipSequence = useRef(0);
|
||||
const relationshipPending = useRef(false);
|
||||
const relationshipContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [recruiterName, setRecruiterName] = useState("");
|
||||
const [recruiterEmail, setRecruiterEmail] = useState("");
|
||||
@@ -60,14 +71,14 @@ export default function CompaniesTable() {
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: t("companiesLoadFailed"),
|
||||
deps: [t],
|
||||
errorMessage: "",
|
||||
},
|
||||
);
|
||||
|
||||
const companies = companiesResource.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (savePending.current) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const editId = Number(params.get("edit") || 0);
|
||||
if (!editId || companies.length === 0) return;
|
||||
@@ -75,11 +86,22 @@ export default function CompaniesTable() {
|
||||
if (!company) return;
|
||||
openEdit(company);
|
||||
params.delete("edit");
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "" }, { replace: true });
|
||||
}, [companies, location.pathname, location.search, navigate]);
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "", hash: location.hash }, { replace: true, state: location.state });
|
||||
}, [companies, location.pathname, location.search, location.hash, location.state, navigate, saving]);
|
||||
|
||||
useEffect(() => () => { relationshipSequence.current += 1; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Restore after MUI has removed the exiting modal.
|
||||
if (focusReturn?.id && document.activeElement === document.body) {
|
||||
containerRef.current?.querySelector<HTMLButtonElement>(`[data-company-${focusReturn.action}="${focusReturn.id}"]`)?.focus();
|
||||
}
|
||||
}, [focusReturn]);
|
||||
|
||||
const openEdit = (c: Company) => {
|
||||
setEditError("");
|
||||
if (savePending.current) return;
|
||||
editCompanyIdRef.current = c.id;
|
||||
setEditError(null);
|
||||
setEditing(c);
|
||||
setRecruiterName(c.recruiterName ?? "");
|
||||
setRecruiterEmail(c.recruiterEmail ?? "");
|
||||
@@ -91,22 +113,40 @@ export default function CompaniesTable() {
|
||||
};
|
||||
|
||||
const openRelationship = async (company: Company) => {
|
||||
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(""); setRelationship(null);
|
||||
const request = ++relationshipSequence.current;
|
||||
relationshipPending.current = true;
|
||||
setRelationshipCompany(company);
|
||||
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(null); setRelationship(null);
|
||||
try {
|
||||
const response = await api.get<CompanyRelationship>(`/companies/${company.id}/relationship`);
|
||||
setRelationship(response.data);
|
||||
if (request === relationshipSequence.current) setRelationship(response.data);
|
||||
} catch (error) {
|
||||
setRelationshipError(getApiErrorMessage(error, t("companiesRelationshipLoadFailed")));
|
||||
if (request !== relationshipSequence.current) return;
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
setRelationshipError({ kind: unauthorized ? "unauthorized" : "unavailable", message: getApiErrorMessage(error, ""), retryable: !unauthorized, status });
|
||||
} finally {
|
||||
setRelationshipLoading(false);
|
||||
if (request === relationshipSequence.current) {
|
||||
relationshipPending.current = false;
|
||||
setRelationshipLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeRelationship = () => {
|
||||
relationshipSequence.current += 1;
|
||||
relationshipPending.current = false;
|
||||
setRelationshipOpen(false);
|
||||
};
|
||||
|
||||
const canSave = useMemo(() => !!editing?.id, [editing]);
|
||||
|
||||
const save = async () => {
|
||||
if (!editing?.id) return;
|
||||
setEditError("");
|
||||
if (!editing?.id || savePending.current) return;
|
||||
savePending.current = true;
|
||||
editContentRef.current?.focus();
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const res = await api.put<Company>(`/companies/${editing.id}`, {
|
||||
name: editing.name,
|
||||
@@ -125,7 +165,10 @@ export default function CompaniesTable() {
|
||||
setEditOpen(false);
|
||||
setEditing(null);
|
||||
} catch (error) {
|
||||
setEditError(getApiErrorMessage(error, t("companiesUpdateFailed")));
|
||||
setEditError(getApiErrorMessage(error, ""));
|
||||
} finally {
|
||||
savePending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,10 +180,10 @@ export default function CompaniesTable() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
|
||||
<Box ref={containerRef} sx={{ minWidth: 0 }}>
|
||||
<ViewStateNotice
|
||||
loading={companiesResource.loading}
|
||||
error={companiesResource.error}
|
||||
error={companiesResource.error ? { ...companiesResource.error, message: companiesResource.error.message || t("companiesLoadFailed") } : null}
|
||||
title={t("companiesLoadFailed")}
|
||||
description={t("companiesLoadFailedBody")}
|
||||
onRetry={companiesResource.reload}
|
||||
@@ -150,17 +193,17 @@ export default function CompaniesTable() {
|
||||
isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{companies.map((c) => (
|
||||
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Paper key={c.id} component="article" aria-label={c.name} variant="outlined" sx={{ p: "var(--app-card-padding)", minWidth: 0 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography component="h2" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{c.name}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<IconButton size="small" data-company-edit={c.id} aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
||||
<IconButton size="small" data-company-relationship={c.id} aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
||||
<PeopleAltOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -181,8 +224,8 @@ export default function CompaniesTable() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer sx={{ borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Table>
|
||||
<TableContainer component={Paper} variant="outlined" role="region" aria-label={t("companies")} tabIndex={0} sx={{ maxWidth: "100%", overflow: "auto" }}>
|
||||
<Table aria-label={t("companies")} sx={{ minWidth: 840, "& th, & td": { overflowWrap: "anywhere" }, "& thead th": { whiteSpace: "nowrap" } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
@@ -191,26 +234,26 @@ export default function CompaniesTable() {
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
<TableCell width={1} align="right" sx={{ whiteSpace: "nowrap" }}>{t("jobTableActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell component="th" scope="row">{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell sx={{ minWidth: 110 }}>{c.source ?? ""}</TableCell>
|
||||
<TableCell sx={{ minWidth: 140 }}>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }}>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
<IconButton size="small" data-company-relationship={c.id} aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
||||
<PeopleAltOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<IconButton size="small" data-company-edit={c.id} aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
@@ -231,45 +274,54 @@ export default function CompaniesTable() {
|
||||
)
|
||||
) : null}
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
|
||||
<Dialog open={editOpen} onClose={() => { if (!savePending.current) setEditOpen(false); }} fullWidth fullScreen={isMobile} maxWidth="sm"
|
||||
slotProps={{ transition: { onExited: () => setFocusReturn({ action: "edit", id: editCompanyIdRef.current }) } }}>
|
||||
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{editError ? <Alert severity="error" sx={{ mt: 1 }}>{editError}</Alert> : null}
|
||||
<DialogContent ref={editContentRef} role="region" aria-label={t("companiesEdit")} tabIndex={-1} aria-busy={saving}>
|
||||
{saving ? <ViewStateNotice compact loading title={t("companiesUpdateFailed")} /> : null}
|
||||
{editError !== null ? <Alert severity="error" sx={{ mt: 1 }}>{editError || t("companiesUpdateFailed")}</Alert> : null}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label={t("companiesName")}
|
||||
disabled={saving}
|
||||
value={editing?.name ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesLocation")}
|
||||
disabled={saving}
|
||||
value={editing?.location ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, location: e.target.value } : p))}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesSource")}
|
||||
disabled={saving}
|
||||
value={editing?.source ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, source: e.target.value } : p))}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("companiesPipelineStage")}
|
||||
disabled={saving}
|
||||
value={pipelineStage}
|
||||
onChange={(e) => setPipelineStage(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesRecruiterName")}
|
||||
disabled={saving}
|
||||
value={recruiterName}
|
||||
onChange={(e) => setRecruiterName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesRecruiterEmail")}
|
||||
disabled={saving}
|
||||
value={recruiterEmail}
|
||||
onChange={(e) => setRecruiterEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesRecruiterLinkedIn")}
|
||||
disabled={saving}
|
||||
value={recruiterLinkedIn}
|
||||
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
@@ -277,6 +329,7 @@ export default function CompaniesTable() {
|
||||
|
||||
<TextField
|
||||
label={t("companiesLastContacted")}
|
||||
disabled={saving}
|
||||
type="date"
|
||||
value={lastContactedAt}
|
||||
onChange={(e) => setLastContactedAt(e.target.value)}
|
||||
@@ -284,6 +337,7 @@ export default function CompaniesTable() {
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesNextContactField")}
|
||||
disabled={saving}
|
||||
type="date"
|
||||
value={nextContactAt}
|
||||
onChange={(e) => setNextContactAt(e.target.value)}
|
||||
@@ -292,18 +346,24 @@ export default function CompaniesTable() {
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3, flexDirection: { xs: "column-reverse", sm: "row" }, gap: 1 }}>
|
||||
<Button onClick={() => setEditOpen(false)} fullWidth={isMobile}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave} fullWidth={isMobile}>
|
||||
<Button onClick={() => { if (!savePending.current) setEditOpen(false); }} disabled={saving} fullWidth={isMobile}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave || saving} fullWidth={isMobile}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={relationshipOpen} onClose={() => setRelationshipOpen(false)} fullWidth fullScreen={isMobile} maxWidth="md">
|
||||
<DialogTitle>{relationship?.company.name ?? t("companiesRelationship")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{relationshipLoading ? <Typography color="text.secondary">{t("loading")}</Typography> : null}
|
||||
{relationshipError ? <Alert severity="error">{relationshipError}</Alert> : null}
|
||||
<Dialog open={relationshipOpen} onClose={closeRelationship} fullWidth fullScreen={isMobile} maxWidth="md"
|
||||
slotProps={{ transition: { onExited: () => setFocusReturn({ action: "relationship", id: relationshipCompany?.id }) } }}>
|
||||
<DialogTitle>{relationship?.company.name ?? relationshipCompany?.name ?? t("companiesRelationship")}</DialogTitle>
|
||||
<DialogContent ref={relationshipContentRef} role="region" aria-label={t("companiesRelationship")} tabIndex={-1} aria-busy={relationshipLoading}>
|
||||
<ViewStateNotice compact loading={relationshipLoading} title={t("companiesRelationshipLoadFailed")}
|
||||
error={relationshipError ? { ...relationshipError, message: relationshipError.message || t("companiesRelationshipLoadFailed") } : null}
|
||||
onRetry={() => {
|
||||
if (!relationshipCompany || relationshipPending.current) return;
|
||||
relationshipContentRef.current?.focus();
|
||||
void openRelationship(relationshipCompany);
|
||||
}} />
|
||||
{relationship ? (
|
||||
<Stack spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
@@ -312,21 +372,21 @@ export default function CompaniesTable() {
|
||||
{renderCompanyMeta(t("companiesNextContact"), relationship.company.nextContactAt ? new Date(relationship.company.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesApplications")}</Typography>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("companiesApplications")}</Typography>
|
||||
{relationship.applications.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoApplications")}</Typography> : (
|
||||
<Stack spacing={1}>{relationship.applications.map((application) => (
|
||||
<Paper key={application.id} variant="outlined" sx={{ p: 1.25, display: "flex", flexWrap: "wrap", gap: 1, alignItems: "center" }}>
|
||||
<Box sx={{ flex: "1 1 220px" }}><Typography sx={{ fontWeight: 700 }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{application.status} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
|
||||
<Button size="small" onClick={() => { setRelationshipOpen(false); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
|
||||
<Box sx={{ flex: "1 1 220px", minWidth: 0 }}><Typography sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{statusLabel(t, application.status)} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
|
||||
<Button size="small" onClick={() => { closeRelationship(); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
|
||||
</Paper>
|
||||
))}</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesRecentContact")}</Typography>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("companiesRecentContact")}</Typography>
|
||||
{relationship.recentContacts.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoContact")}</Typography> : (
|
||||
<Stack spacing={1}>{relationship.recentContacts.map((contact) => (
|
||||
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider", overflowWrap: "anywhere" }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{contact.subject || contact.channel || t("companiesContact")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{contact.jobTitle} · {new Date(contact.date).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} · {contact.from}</Typography>
|
||||
</Box>
|
||||
@@ -336,9 +396,9 @@ export default function CompaniesTable() {
|
||||
</Stack>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setRelationshipOpen(false)}>{t("close")}</Button></DialogActions>
|
||||
<DialogActions><Button onClick={closeRelationship}>{t("close")}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function Actions() {
|
||||
return <><button onClick={() => toast("Saved", "success")}>Show feedback</button><button onClick={() => setLanguage("nb")}>Change language</button></>;
|
||||
}
|
||||
|
||||
test("feedback does not reload company data; changing locale still reloads translated states", async () => {
|
||||
test("feedback and locale changes translate company presentation without reloading company data", async () => {
|
||||
localStorage.clear();
|
||||
(api.get as jest.Mock).mockResolvedValue({ data: [{ id: 3, name: "Acme" }] });
|
||||
render(<ToastProvider><I18nProvider><MemoryRouter><Actions /><CompaniesTable /></MemoryRouter></I18nProvider></ToastProvider>);
|
||||
@@ -25,5 +25,5 @@ test("feedback does not reload company data; changing locale still reloads trans
|
||||
expect(reads()).toBe(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change language" }));
|
||||
await screen.findByRole("button", { name: "Rediger: Acme" });
|
||||
await waitFor(() => expect(reads()).toBe(2));
|
||||
await waitFor(() => expect(reads()).toBe(1));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user