feat(ui): migrate Career Profile structured editor
This commit is contained in:
@@ -14,9 +14,11 @@ Entry is the completed Phase6 successor receipt `b5319981ad3d1bd635daff861453d23
|
||||
|
||||
| P7-02 Career overview/evidence and final implemented checkpoint outcome | `8b4d5c21125e800199d3ecc2d9c059a80ac5125d` | [Report](p7-02/README.md#career-overviewevidence-commit), [frozen finite evidence](p7-02/evidence/career-results.json): affected46/3 (+14 cases), native14 and fresh ordinary P2-D01/mobile-override2, types/lint/build pass; all22 final PNGs actually inspected. BuildqG82wPnFf-IsAkFvXkmn6,75-file export;0 violations/99 incompletes and M09/M11/manual/provider qualifications retained. Actual Delete-cancel RED/repair and Windows CRLF test correction retained. Implemented P7-02 CONDITIONAL GO; [final Career hygiene](p7-02/evidence/career-static-checks.json) PASS05:48:32UTC (four commands0,9docs347links0errors/57+32 retained); normal push directly verified2026-10-12 05:51:04.849644UTC: localHEAD=origin=live development8b4d5c2, local/origin/live main27657f3 unchanged, worktree clean at observation. Actual receipt appended by successor; P7-03 entry satisfied with mandatory stable-ID/concurrency conditions. Fullfrontend610/backend1,034 derived only;57/32 unchanged/main NO-GO |
|
||||
|
||||
| P7-03 separate forward source-ID/null-metadata compatibility repair | `3047b6c4a87c43de6f397e4a3d109be9ce3ddeca` (local/unpushed) | [Prerequisite report](p7-03/README.md#prerequisite-runtime-and-finite-acceptance), [frozen finite results](p7-03/evidence/source-id-results.json): unit46/3 (+8/+1 suite), native2 and fresh ordinary preservation2, types/lint/build pass; all11 readback flagsTRUE and two final PNGs inspected with capture qualifications. One frontend normalizer owner only; API binary unchanged/no new backend build. BuildbzQiDlr6LeM9Gsel4WRS8;0 new axe attachments. Bounded forward repair GO; [final static checks](p7-03/evidence/source-id-static-checks.json) PASS06:21:53UTC at earlier scope. Later staged whitespace reported1; root committed despite failure. Historical committed check actualexit2 reports only new EOF blank line75. Repair remains unpushed pending a normal whitespace/docs successor and fresh staged gate; no runtime/assertion change. Old orphaned overrides and concurrency remain unrepaired; full618/90/browser216/backend1,034 derived only;57/32 unchanged/main NO-GO |
|
||||
| P7-03 separate forward source-ID/null-metadata compatibility repair | `3047b6c4a87c43de6f397e4a3d109be9ce3ddeca` | [Prerequisite report](p7-03/README.md#prerequisite-runtime-and-finite-acceptance), [frozen finite results](p7-03/evidence/source-id-results.json): unit46/3 (+8/+1 suite), native2 and fresh ordinary preservation2, types/lint/build pass; all11 readback flagsTRUE and two final PNGs inspected with capture qualifications. One frontend normalizer owner only; API binary unchanged/no new backend build. BuildbzQiDlr6LeM9Gsel4WRS8;0 new axe attachments. Bounded forward repair GO; [final static checks](p7-03/evidence/source-id-static-checks.json) PASS06:21:53UTC at earlier scope. Later staged whitespace reported1; root committed despite failure. Historical committed check actualexit2 reports only new EOF blank line75. Repair was held unpushed at that failure gate; subsequent hygiene704637a and normal two-commit push are directly verified in the next row. No runtime/assertion change. Old orphaned overrides and concurrency remain unrepaired; full618/90/browser216/backend1,034 derived only;57/32 unchanged/main NO-GO |
|
||||
|
||||
| P7-03 repair hygiene successor | PENDING normal commit/push | Remove exactly one final blank line from the new profileCv.test.ts; preserve as-run immutable test/source/runtime identity and record the unhandled staged failure/local3047b6c4 honestly. No logic/assertion/count change, no test/build/native rerun. [Fresh corrected staged static checks](p7-03/evidence/source-id-hygiene-correction-static-checks.json) PASS06:32:12UTC (four commands0,10docs374links0errors/57+32 retained); normal hygiene successor commit and two-commit push pending; no amend/reset/force push/main change |
|
||||
| P7-03 repair hygiene successor | `704637ab375c32d14aa46f9d4ef0f210136de4d9` | Remove exactly one final blank line from the new profileCv.test.ts; preserve as-run immutable test/source/runtime identity and record the unhandled staged failure/local3047b6c4 honestly. No logic/assertion/count change, no test/build/native rerun. [Fresh corrected staged static checks](p7-03/evidence/source-id-hygiene-correction-static-checks.json) PASS06:32:12UTC (four commands0,10docs374links0errors/57+32 retained); normal hygiene successor704637a and chronological repair3047+7046 push directly verified2026-10-12 06:34:48.620963UTC: localHEAD=origin=live704637a, local/origin/live main unchanged27657f3, clean worktree at observation; no amend/reset/force push/main change |
|
||||
|
||||
| P7-03 structured-editor presentation | PENDING normal commit/push | [Final report](p7-03/README.md#final-structured-editor-result), [immutable finite evidence](p7-03/evidence/editor-results.json): affected54/4 (+8/+1 suite), editor11/source-ID2/Career14/ordinary preservation2, fresh types/lint/build pass on W4fUO7_GUC0Qh5FuL3qVL; all13 final PNGs actually reviewed. Editor13 scans0violations/377 incompletes and separate Career22 scans0/99 retained with M09/M11/manual gates. Expanded raw/dirty-locale/pending/failure/readback proof passes; meaningful RED and coverage-limited historical captures remain. Implemented editor CONDITIONAL GO; final staged hygiene and normal push PENDING. Import/history and whole P7-03 unfinished; full626/91/browser227/backend1,034 derived only;57/32 unchanged/main NO-GO |
|
||||
|
||||
Hashes and remote receipts are appended by a successor to avoid self-referential commit hashes. Each row records actual commands/results, source scope, remaining conditions, normal push and immediate direct remote identity; partial or blocked evidence is recorded without inventing success. Subsequent receipt metadata is a separately reviewable documentation unit, with its own hash reported after push rather than an endless receipt chain.
|
||||
|
||||
|
||||
@@ -101,10 +101,14 @@ Two independent read reviews identify the existing normalizer dropping job/educa
|
||||
|
||||
### P7-03 forward-ID repair disposition
|
||||
|
||||
[Source-ID results](p7-03/evidence/source-id-results.json) reproduce ID/override loss and null→0 metadata before a bounded frontend normalizer correction; final unit46/3, native2/preservation2, types/lint/build and11 exact readback flags pass. The +8/+1-suite addition gives only derived618/90; browser216/backend1,034 likewise are not new full runs. Prior API DLL/91 targeted guard evidence is inherited unchanged. No automatic unsafe matching/remap of older orphaned overrides occurs; historical records stay. [Final source-ID hygiene](p7-03/evidence/source-id-static-checks.json) PASS at earlier observation; final staged EOF whitespace failed, local3047b6c4 remains unpushed pending the transparent test-whitespace/docs successor and normal push; [fresh corrected staged gate](p7-03/evidence/source-id-hygiene-correction-static-checks.json) PASS (four commands0,10docs374links0errors). No runtime/assertion change or evidence loss.
|
||||
[Source-ID results](p7-03/evidence/source-id-results.json) reproduce ID/override loss and null→0 metadata before a bounded frontend normalizer correction; final unit46/3, native2/preservation2, types/lint/build and11 exact readback flags pass. The +8/+1-suite addition gives only derived618/90; browser216/backend1,034 likewise are not new full runs. Prior API DLL/91 targeted guard evidence is inherited unchanged. No automatic unsafe matching/remap of older orphaned overrides occurs; historical records stay. [Final source-ID hygiene](p7-03/evidence/source-id-static-checks.json) PASS at earlier observation; final staged EOF whitespace failed, local3047b6c4 remains unpushed at that failure gate; the transparent test-whitespace/docs successor704637a and normal repair3047+7046 push are now directly verified06:34:48UTC, main unchanged/clean at observation; [fresh corrected staged gate](p7-03/evidence/source-id-hygiene-correction-static-checks.json) PASS (four commands0,10docs374links0errors). No runtime/assertion change or evidence loss.
|
||||
|
||||
RD21 now has actual stale200/last-writer/append-restore/raw-current/owner-denial characterization, retaining its concurrency risk before any save/history semantics change. Dirty-locale and held-PUT/pending-write ownership/recovery still require proof for the presentation slice. Two final PNG reviews remain qualified: original editor sticky-scroll capture and blended frozen-dialog/workspace image, with transition cause unconfirmed. No new axe attachments or human/device/theme/settled-dialog acceptance; RD06/RD07/RD14/P7-06/P7-11 requirements remain. Source-ID forward repair does not close full C01/C07 or import/history/production obligations.
|
||||
|
||||
### P7-03 structured-editor conditional disposition
|
||||
|
||||
[Final editor evidence](p7-03/evidence/editor-results.json) adds bounded dirty-locale/no-refetch, held structured/raw editing locks, localized generic fallback and durable server-detail retry proof: affected54/4, ordinary editor11 plus applicable source-ID2/Career14/preservation2, fresh types/lint/build PASS; all13 final PNGs actually reviewed. Root-overflow is0 for the finite samples. Editor13 scans retain0violations/377 incompletes (362overlap,13short badge,1obscured actual400 context,1hidden import-select portal); separate Career22 scans retain0/99 including3hidden evidence-select portals. M09/M11 and capture-context focus, humanAT/touch/zoom/provider/output requirements remain open. Expanded raw-helper RED and earlier collapsed-panel coverage gap are preserved; original first-build export loss remains explicit. Implemented editor CONDITIONAL GO with final staged hygiene/push PENDING. No RD21 concurrency repair, automatic orphan remap, full regression, import/history closure or production GO is inferred; all57/32 statuses and C11/Phase8 gates remain.
|
||||
|
||||
The early Phase6 runner retained exact failure commands/reports/logs but reused result folders, so not all old screenshots/traces/error-context paths remain resolvable. Later runs archive the whole previous output; current successful attachments have exact provenance. Preserve that limit rather than claiming complete old failure-trace retention. Native Windows Chromium/local SQLite/synthetic fixtures and finite PDF oracles do not establish deployed Linux, human semantics, physical devices, real provider quality or production load.
|
||||
|
||||
API transferSize0 is unavailable cross-origin timing information, not zero network bytes. CDP document/node/heap snapshots can retain earlier navigation/CV iframe documents and do not prove leak/growth. The existing measured Board/Dashboard candidates require baseline/comparative evidence before a repair or capacity claim.
|
||||
|
||||
@@ -115,7 +115,11 @@ Stable job/education/certification/project IDs dropped by the existing normalize
|
||||
|
||||
### P7-03 forward-ID prerequisite reconciliation
|
||||
|
||||
[Finite repair evidence](p7-03/evidence/source-id-results.json) adds ordinary unit46/3 (+8 cases/+1 suite) and native2 plus fresh P2-D01/mobile-override2 with types/lint/build passing. C01/C02/C07/E09 optional source IDs/nullable metadata, canonical payload/readback and selected/sibling/history/frozen boundaries are preserved; all11 final flags areTRUE. The second case characterizes stale200/append-restore/raw-current/foreign-owner denial; it does not close RD21 concurrency. No schema/backend contract or PDF/model action changes. Both lanes have0 axe attachments, and two inspected PNGs remain finite capture qualifications, not settled-dialog/design conformance. [Final source-ID hygiene](p7-03/evidence/source-id-static-checks.json) PASS at earlier observation; later staged EOF whitespace failure requires a transparent normal hygiene successor/fresh staged recheck before push. [Corrected staged gate](p7-03/evidence/source-id-hygiene-correction-static-checks.json) now PASS (four commands0,10docs374links0errors), normal hygiene successor/push pending. As-run hashes remain immutable; all57 statuses/32 routes unchanged. Older orphaned overrides require separate justified recovery, and P7-03 structured/import presentation remains pending.
|
||||
[Finite repair evidence](p7-03/evidence/source-id-results.json) adds ordinary unit46/3 (+8 cases/+1 suite) and native2 plus fresh P2-D01/mobile-override2 with types/lint/build passing. C01/C02/C07/E09 optional source IDs/nullable metadata, canonical payload/readback and selected/sibling/history/frozen boundaries are preserved; all11 final flags areTRUE. The second case characterizes stale200/append-restore/raw-current/foreign-owner denial; it does not close RD21 concurrency. No schema/backend contract or PDF/model action changes. Both lanes have0 axe attachments, and two inspected PNGs remain finite capture qualifications, not settled-dialog/design conformance. [Final source-ID hygiene](p7-03/evidence/source-id-static-checks.json) PASS at earlier observation; later staged EOF whitespace failure requires a transparent normal hygiene successor/fresh staged recheck before push. [Corrected staged gate](p7-03/evidence/source-id-hygiene-correction-static-checks.json) now PASS (four commands0,10docs374links0errors), normal repair3047+hygiene704637a push directly verified06:34:48UTC, main unchanged/clean at observation. As-run hashes remain immutable; all57 statuses/32 routes unchanged. Older orphaned overrides require separate justified recovery, and P7-03 structured/import presentation remains pending.
|
||||
|
||||
### P7-03 structured-editor reconciliation
|
||||
|
||||
[Final editor evidence](p7-03/evidence/editor-results.json) records affected54/4 and native editor11/source-ID2/Career14/preservation2 PASS, with fresh types/lint/build and13 actual final image reviews. C01/C02/E08/E09 retain canonical fields, contextual actions, structured/raw dirty-locale drafts, held-save locks/snapshot ownership, recoverable500/actual400 feedback, keyboard/labels and finite320–1920 reflow. C07/W07 retain matching source IDs, sibling overrides and ordinary frozen P2-D01/mobile-override readback. No schema, authorization, data-saving, document-renderer or model qualification change occurs. Editor13 all-enabled scans0/377 and separate Career22 scans0/99 retain every incomplete category and M09/M11 contextual/reader qualification; source-ID/preservation have0axe scans. Implemented editor CONDITIONAL GO, final staged hygiene/normal push PENDING; import/history and whole P7-03 unfinished. All57 rows remain56 PARTIAL/C11 DEFERRED and32 routes retained; no wholesale PASS follows.
|
||||
|
||||
[INVENTORY.md](p7-00/INVENTORY.md#current-route-coverage) reconciles exactly32 current App patterns against [route acceptance](../phase-5/p5-15/ROUTE_ACCEPTANCE.md); all whole-route groups remain PARTIAL. Preserve Board/Application/mail aliases and actual query/hash/state behavior, section/sub precedence, safe internal from, numeric navigation-helper output, capture consumption, focusJobId and provider/token destinations. No Documents/Notifications/aggregate Admin/new Career destination is inferred without contract/content evidence.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-00](p7-00/README.md) | Fresh scope inventory and fixture baseline | Complete finite identity/route/matrix/static checks; inherited runtime separated. Push receipt recorded by the successor before page changes |
|
||||
| [P7-01](p7-01/README.md) | Authentication and account-security presentation | Public sub-slice CONDITIONAL GO, pushed8ad720d with direct receipt; account implementation/runtime/actual8-image review CONDITIONAL GO (573/89 +6/22/10); pushedad7b419 with final static PASS/direct receipt19:16:15UTC; main unchanged |
|
||||
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections, hygiene PASS and d21501aa push verified23:55:12UTC. Separate SQLite evidence-list repair91 guards/HTTP1/Release passed, pushed01281f52/direct receipt00:06:47UTC with unfinished Career work preserved. Career affected46/3/native14/preservation2/types/lint/build and actual22-image review pass; implemented checkpoint CONDITIONAL GO with99 incompletes/manual gates. Career final hygiene PASS (four commands0,9docs347links0errors); normally pushed8b4d5c2/direct localHEAD=origin=live receipt05:51:04UTC, clean worktree/main unchanged |
|
||||
| [P7-03](p7-03/README.md) | Career structured editing and import review | P7-02 pushed8b4d5c2; separate forward ID/null-metadata repair unit46/native2/preservation2/types/lint/build and finite readback/image review pass; local repair3047b6c4 unpushed after final staged EOF whitespace failure, corrected full staged hygiene PASS06:32:12UTC (four commands0,10docs374links0errors), normal hygiene successor/push pending. Stale contract characterized without concurrency repair; dirty-locale/pending-write proof precedes structured/import presentation; planned |
|
||||
| [P7-03](p7-03/README.md) | Career structured editing and import review | Forward-ID/null-metadata repair3047 and hygiene704637a normally pushed/directly verified06:34:48UTC; original staged EOF failure retained. Structured-editor implemented scope CONDITIONAL GO: affected54/4, native editor11/source-ID2/Career14/preservation2, types/lint/build PASS; all13 final PNGs inspected,13 scans0violations/377 incompletes retained. Final staged hygiene/normal push PENDING; import/history presentation remains PENDING. Stale contract characterized without concurrency repair; manual/provider/production gates remain |
|
||||
| P7-04 | CV library and public entry | Existing gallery/editor and P7-03 source language; planned |
|
||||
| P7-05 | Discovery, Companies, Offers and Reminders | Existing domain owners and Jobs return context; planned |
|
||||
| P7-06 | Remaining Application forms and global correspondence | Existing seven-section workspace, immutable packages and draft/send owners; planned |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# P7-03 — Career structured editing and import review
|
||||
|
||||
**Status: accepted entry with mandatory dependencies; implementation/verification results PENDING.** Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): a structured-editor presentation commit, then a separate import/history presentation commit. The newly identified source-ID prerequisite is a separate bounded repair before those presentation changes; it does not introduce another checkpoint or schema.
|
||||
**Status: structured editor CONDITIONAL GO; final staged hygiene PASS, normal push PENDING. Import/history presentation remains PENDING.** Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): a structured-editor presentation commit, then a separate import/history presentation commit. The source-ID prerequisite was a separate bounded repair before those presentation changes; it does not introduce another checkpoint or schema.
|
||||
|
||||
## Accepted entry and evidence limits
|
||||
|
||||
@@ -18,7 +18,7 @@ First reproduce the loss in ordinary normalizer/payload and actual local canonic
|
||||
|
||||
The initial bounded unit RED at2026-10-12 05:54:52UTC passes25/fails5 of30 ordinary cases: six new normalizer cases plus existing24 Profile cases with the canonical payload assertion strengthened. Actual native baseline06:06:22–06:06:36UTC passes1/fails1 on unchangedqG82; a selected-DTO association timestamp fixture flaw was then corrected. Authoritative native RED06:08:57–06:09:10UTC again passes1/fails1: requested/persisted IDs fail, override title/bullet change from matching to nonmatching, and three null metadata fields become0. Selected/sibling DTOs, both histories and frozen content remain unchanged in the corrected proof. Separate first/corrected command/report/log/trace/embedded-attachment capsules are preserved under `.artifacts/phase7/p7-03/source-id-repair/baseline/` with0skip/retry/flaky; no PDF or concurrency repair follows.
|
||||
|
||||
Two metadata cases extend the bounded new normalizer suite to8; revised unit RED06:08:59UTC passes26/fails6 of32 cases, including valid0/finite/numeric-string preservation. The approved frontend compatibility repair includes eight optional-ID additions and four nullish metadata guards in profileCv.ts, retaining existing field/payload contracts and valid numeric behavior. Baseline profileCv/CareerProfilePage raw snapshots match git show8b4d5c2 canonical LF bytes. Fresh **unit46/3 GREEN passed06:10:43UTC** (normalizer8+existingProfile24+workspace14); final native2/preservation2 and app/browser types/lint/build pass; later final staged whitespace failed; corrected full staged recheck PASS; normal hygiene successor/push remains **PENDING**. Null confidence is unit-proven; native RED captures the other three null fields. Planned618/90 adds eight cases/one suite to prior derived610/89, superseding the earlier six-case616 expectation; neither is a fresh full run. Forward ID preservation cannot safely remap older orphaned overrides automatically: historical records remain, with recovery requiring a separate justified decision.
|
||||
Two metadata cases extend the bounded new normalizer suite to8; revised unit RED06:08:59UTC passes26/fails6 of32 cases, including valid0/finite/numeric-string preservation. The approved frontend compatibility repair includes eight optional-ID additions and four nullish metadata guards in profileCv.ts, retaining existing field/payload contracts and valid numeric behavior. Baseline profileCv/CareerProfilePage raw snapshots match git show8b4d5c2 canonical LF bytes. Fresh **unit46/3 GREEN passed06:10:43UTC** (normalizer8+existingProfile24+workspace14); final native2/preservation2 and app/browser types/lint/build pass; later final staged whitespace failed; corrected full staged recheck PASS; normal chronological repair3047b6c4/hygiene704637a push directly verified06:34:48UTC. Null confidence is unit-proven; native RED captures the other three null fields. Planned618/90 adds eight cases/one suite to prior derived610/89, superseding the earlier six-case616 expectation; neither is a fresh full run. Forward ID preservation cannot safely remap older orphaned overrides automatically: historical records remain, with recovery requiring a separate justified decision.
|
||||
|
||||
### Prerequisite runtime and finite acceptance
|
||||
|
||||
@@ -28,7 +28,7 @@ Root verified all11 readback flags TRUE: IDs match all three recorded source sna
|
||||
|
||||
Root actually opened two final PNGs plus the corrected-RED failure viewport image. The final390x4683 existing-editor image reaches Save/content and captures sticky chrome at current scroll. The frozen-package image blends underlying workspace/chrome through the dialog; its possible transition cause is undiagnosed and settled-dialog visual acceptance remains P7-06/P7-11 qualification. These finite inspections are not a cosmetic migration or new responsive/theme/human acceptance. Both native lanes contain **0 axe attachments**; the earlier P7-02 incomplete inventory/manual gates remain, and no new axe clearance is inferred.
|
||||
|
||||
**Bounded runtime compatibility repair GO; corrected full staged hygiene PASS, normal hygiene successor/push PENDING.** Structured-editor and import/history presentation, old orphaned-override recovery and all manual/production gates remain pending. Fullfrontend618/90 and ordinarybrowser216 are derived only after this +8/+1-suite addition; no fresh full-suite result replaces inherited573/89.
|
||||
**Bounded forward compatibility repair GO; corrected full staged hygiene PASS, normal repair/hygiene push verified.** Structured-editor and import/history presentation, old orphaned-override recovery and all manual/production gates remain pending. Fullfrontend618/90 and ordinarybrowser216 are derived only after this +8/+1-suite addition; no fresh full-suite result replaces inherited573/89.
|
||||
|
||||
Slice-specific [source-ID static checks](evidence/source-id-static-checks.json) passed2026-10-12 06:21:50–06:21:53UTC: four secret/working/staged/entry-whitespace commands exit0;10 Markdown documents/370 local links checked with0errors at observation;57 qualified groups/32 routes retained, main unchanged. Canonical UTF-8/LF SHA256 is `3a910cd0041f2cf855484c161b21bc4f4b1de7446af80600315b4fcf18e76863`; exact static snapshot/log bytes are retained in the final capsule. Root independently verified81 refs/62 unique files and75-file frozen export before later review/receipt metadata. Normal push receipt remains pending; no new runtime or manual claim follows.
|
||||
|
||||
@@ -38,10 +38,52 @@ Root created local **unpushed** repair `3047b6c4a87c43de6f397e4a3d109be9ce3ddeca
|
||||
|
||||
As-run test bytes/hashes remain frozen at3047b6c4; the original normalizer-test canonical hash now points to its immutable snapshot. Corrected test raw/LF metadata and exact historical failure command/log are recorded separately in the manifest. A normal successor commit will contain only this test whitespace and transparent evidence/docs correction after a **fresh full staged gate**. Both commits remain chronological, with no rewrite/force push. Prior06:21 static PASS is historical, not the corrected final gate; fresh corrected full staged gate now passes; normal hygiene successor commit and normal push remain pending.
|
||||
|
||||
Fresh [hygiene-correction static checks](evidence/source-id-hygiene-correction-static-checks.json) passed2026-10-12 06:32:09–06:32:12UTC with all four commands0,10docs/374 local links0errors at observation,57 groups/32 routes retained and main unchanged. Canonical UTF-8/LF SHA256 is `24c3147e21e266e2b2d9e22cc1f0f716da791b730d280328d3ceae02fb5e67e1`; exact static/log bytes are frozen separately. Original failure,3047 source applicability and all as-run snapshots remain unchanged. The normal hygiene successor and subsequent two-commit push are pending.
|
||||
Fresh [hygiene-correction static checks](evidence/source-id-hygiene-correction-static-checks.json) passed2026-10-12 06:32:09–06:32:12UTC with all four commands0,10docs/374 local links0errors at observation,57 groups/32 routes retained and main unchanged. Canonical UTF-8/LF SHA256 is `24c3147e21e266e2b2d9e22cc1f0f716da791b730d280328d3ceae02fb5e67e1`; exact static/log bytes are frozen separately. Original failure,3047 source applicability and all as-run snapshots remain unchanged. They were pending at this staged-gate observation; the actual normal two-commit push is recorded in the structured-editor entry below.
|
||||
|
||||
Before changing save/history ownership, resolve or explicitly bound RD21 canonical profile/job stale-save and creation-intent behavior with actual stale-write/recovery tests. Prove dirty drafts survive UI-locale changes and relevant read-retry/navigation boundaries; prove held-PUT/pending-write snapshots, edit/save completion ownership and recoverable failure. Existing verified CV revision handling is a different contract. Presentation migration must retain current data-saving semantics and existing guards; do not infer a missing server token or invent one.
|
||||
|
||||
## Structured-editor entry after prerequisite push
|
||||
|
||||
Actual normal push2026-10-12 06:34:48.620963UTC includes repair `3047b6c4a87c43de6f397e4a3d109be9ce3ddeca` and hygiene successor `704637ab375c32d14aa46f9d4ef0f210136de4d9`: localHEAD=origin=live development704637a, local/origin/live main27657f3 unchanged and clean worktree at observation. The successor appends this receipt; both commits and the unhandled original whitespace failure remain chronological. All three measured test canonical references now use their immutable as-run snapshots after legitimate editor-test changes; source3047 and corrected hygiene test metadata remain distinct, with no hash/runtime reinterpretation.
|
||||
|
||||
CareerProfilePage/CareerProfileSections raw baselines are frozen under `.artifacts/phase7/p7-03/editor/baseline/source/`, canonical LF equal git show704637a. Baseline translations are the **committed Git blob**, SHA256 `fea1eb8ba58a5b4ff86c33fa651ec542fe6df46dcbade226790737dc5994d30c`, rather than the already edited worktree catalog. Root prepared five new bilingual keys and five existing-copy entries for meaningful unit RED expectations. Native baseline uses unchanged prebuiltbzQi/704637a old labels and H3/UI contract; no presentation PASS follows from the new test catalog. Existing75-file export is referenced without duplication. Initial editor RED is recorded below; implementation and final acceptance remain **PENDING**. Latest full573/89 remains inherited and618/90/browser216/backend1,034 are derived only.
|
||||
|
||||
### Structured-editor failing baseline
|
||||
|
||||
Actual first native execution2026-10-12 06:38:45–06:40:15UTC fails all11 ordinary cases, with0passes/skips/flaky/retries. The affected unit pack06:40:10–06:40:44UTC passes38/fails8 of46 cases across3 suites: new editor1/7, existing Profile23/1 and workspace14/0;0pending/todo or console errors. The deliberate old-copy expectation failure remains visible. Raw commands/reports/logs, the entire native results/trace folder,28 decoded attachments including12 PNGs, and seven as-run source/test/catalog owners are frozen under `.artifacts/phase7/p7-03/editor/baseline/native-first/`. Product page/sections snapshots still equal the accepted704637a baseline. Native raw report SHA256 is `66aa335e861d61d7255ae93875a850e63788aafb93d1066f96d62febdc4ce55a`; `baseline-results.json` preserves full rule/node/check vectors and exact provenance.
|
||||
|
||||
The reproduction shows a locale change repeating GET and overwriting phone/raw drafts, held PUT leaving35 fields/26 actions and raw editing enabled while Bold changes the draft, and an actual controlled400 showing no form error. Root actually inspected only the320px NB/dark baseline image: its350px-wide full-page capture shows30px overflow while the form fields remain inside. Other PNGs are retained without claiming individual inspection. Twelve all-enabled scans record0violation nodes/**408 incomplete contrast occurrences**:396 overlapping-background checks and12 short-text badges. Per-state counts are6 for the locale view,37 for six narrow/pending/error views and36 for five desktop/tablet views; M09/M11 contextual/human qualification remains. These failing results establish the bounded repair scope; they provide no editor GO, settled visual conformance or import/history acceptance.
|
||||
|
||||
A separate overlapping narrow diagnostic executed2026-10-12 10:37:40–10:37:51UTC fails the same320px NB/dark ordinary case, with0skip/flaky/retry. Its raw report/log/command/results/trace, diagnostic audit and PNG are frozen under `.artifacts/phase7/p7-03/editor/baseline/native-narrow/`; raw report SHA256 `d4e2a23c3ad880c50a7a23cc3e5cb9da02f740caac347cc52893074a313a77a8`. The actual DOM records30px root overflow and20 overflowing elements, including outer navigation/editor boxes ending at349.6875px, while all35 form fields end at or before314.6875px. This establishes geometry, with source cause/repair requiring separate review. Its single scan retains0violations/37 incomplete contrast occurrences; it overlaps the first baseline and adds no test or independent full-scope acceptance count. No individual inspection of its new PNG is claimed.
|
||||
|
||||
### Structured-editor unit-green candidate
|
||||
|
||||
Fresh affected unit execution2026-10-12 10:46:11–10:46:43UTC passes54/4 with0failed/pending/todo: editor8+existing Profile24+workspace14+normalizer8. App TypeScript exits0 at10:46:21–10:46:32UTC. Three product owners, four unit owners and the native spec, plus exact command/report/log bytes, are snapshotted under `.artifacts/phase7/p7-03/editor/candidate-20261012T104643Z/`. This is a **candidate**, awaiting final browser TypeScript/lint/build, ordinary native editor11/sourceID2/Career14/preservation2, actual visual/axe contextual review and staged hygiene/push. The eight added editor cases/one suite yield frontend626/91 as a derived expectation only; existing Profile/workspace expectation updates add no cases. Import/history presentation and manual/production gates remain pending.
|
||||
|
||||
### Native candidate history; acceptance withheld
|
||||
|
||||
The first post-implementation ordinary editor run10:47:48–10:48:38UTC passes8/fails3 of11 with0skip/flaky/retry. Actual durable-error alerts omitted the general failure heading beside server detail, and a320px ProFeatureNotice message was scrollable without keyboard focus. Its12 scans retain8 violations (7 inactive helper-text contrast and1 scrollable-region focusability) plus374 incomplete contrast occurrences. Root then corrected the local notice wrapping and error heading. The earlier browser TypeScript duplicate diagnostic property TS1117 failure is retained; removing only the duplicate property left the stronger diagnostic and fresh browser typecheck passed. Raw commands/reports/logs/results/traces, decoded evidence and parent-confirmed original candidate sources remain under `.artifacts/phase7/p7-03/editor/attempt1-20261012T104838Z/`. **Original DwxCC BUILD_ID/export bytes were overwritten by the corrected build before the freeze could copy them**; exact build command/log and directly observed identity remain, with no retroactively reconstructed export claim.
|
||||
|
||||
The corrected `jjBcTV3XoPOhWzOYiJ0FR` candidate passes affected54/4 and app/browser types/lint/build, then ordinary editor11/sourceID2/Career14/preservation2 at10:52:33–10:54:46UTC:29 finite overlapping cases,0skip/flaky/retry. Its75-file export, source/tests/API/config, reports and decoded evidence are frozen under `.artifacts/phase7/p7-03/editor/attempt2-20261012T105446Z/` as a **historical attempt with acceptance withheld**; the [current editor evidence](evidence/editor-results.json) records the final superseder below. Editor12 all-enabled scans retain7 actual held-save helper-text contrast violations: six4.48:1 on white and one4.14:1 on the secondary surface, all `.Mui-disabled` “One item per line.” descriptions. They are retained rather than silently excluded. Incompletes374 comprise361 background overlaps,12 short-text badges and1 partially obscured error alert. The separate22 Career regression scans retain0violations/99 incompletes; source-ID/preservation lanes contain0axe scans. All12 editor root-overflow measurements are0, but native PASS alone supplies no axe or manual GO. Parent viewed three images (NB320, EN360 and EN1920); nine others have no individual review claim at that observation. Further corrections and final acceptance follow below. Whole P7-03 import/history remains incomplete.
|
||||
|
||||
### Final structured-editor result
|
||||
|
||||
[Final editor evidence](evidence/editor-results.json) records three changed product owners and five measured test owners, preserved under `.artifacts/phase7/p7-03/editor/final-expanded/`. The editor now uses shared sections and semantic headings, contextual remove actions, localized instructions and save/error feedback, and narrow layouts. UI-locale changes retain structured/raw dirty drafts without repeating their source GET. Held saves lock the submitted editing surface, including rich-text and expanded raw controls; durable failures retain draft and server detail with a localized heading/retry. Generic fallback errors follow the current UI locale. The local ProFeatureNotice wraps, and disabled helpers use the existing text-secondary token in both structured and raw sections. Canonical saving, source IDs, sibling overrides, histories and frozen documents keep their established owners and semantics.
|
||||
|
||||
Fresh affected **54/4 PASS** at2026-10-12 11:13:30–11:14:03UTC comprises eight new editor cases plus existing Profile24/workspace14/normalizer8, with0failed/pending/todo. App/browser TypeScript, lint and production build exit0; the latest browser TypeScript at11:16:54–11:16:58 covers the final native oracle. Build `W4fUO7_GUC0Qh5FuL3qVL` has75 frozen exported files/digest `6e60c305555153d3379be3dac24c0845cf7665855bb7bcba95b26b9131cfd33c`; three-product LF digest `75fa95c655d9e3361af9209a82ce9e64f9a6091a4806752bdb32fa76a7be5b1a`. API identity is unchanged; no new backend build/test is claimed.
|
||||
|
||||
Final ordinary editor **11/11 PASS** at11:16:54–11:17:44UTC includes13 captured states. Its strengthened oracle requires the raw disclosure expanded, textarea visible/in viewport, nonempty field inventories in both areas, expected raw label and0axe violations in every captured state. Source-ID2/Career14/ordinary P2-D01 and mobile-override2 also pass on the same unchanged export at11:14:58–11:16:22UTC. These29 finite overlapping cases have0skipped/unexpected/flaky/retries; the final editor rerun supersedes its earlier test-only capture/geometry oracle, while the other18 unchanged-source results remain applicable. Full frontend **573/89 remains inherited**; **626/91**, browser227 and backend1,034 are derived expectations only. Added editor8/+1 suite and native11 are the only new count increments here.
|
||||
|
||||
Root actually inspected **all13 final PNGs**: locale/held structured/held expanded raw/failed save/actual400 plus eight finite width-language-theme samples spanning320–1920px and1280x320 landscape. Recorded geometry has0accidental root overflow. Expanded raw held390 visibly contains the disabled textarea/readable helper; locale1440 retains the dirty raw draft. Deep-scroll/full-page sticky chrome, visible skip-link and toast overlays retain capture/focus qualifications. Controlled500 and actual400 English server details are retained provenance, alongside localized product headings/fallbacks. No physical input, browser zoom, human assistive technology, density matrix, provider or production qualification is inferred.
|
||||
|
||||
Editor **13 all-enabled axe scans** retain **0violation nodes/377 incomplete occurrences**:362 contrast background overlaps,13 invisible-badge short-text checks, one partially obscured actual400 toast/alert context and one aria-hidden-focus context at the hidden MUI select portal `#menu-` (`li[data-value=import]`, tabindex0). The separate Career22 scans retain0/99 (74contrast overlaps,22badges,3hidden evidence-select portal contexts); source-ID/preservation lanes have0axe attachments. Every category/context remains retained, with full vectors in hashed raw/extracted reports. M09/M11 reader/portal-focus and contextual contrast/forced-colors requirements remain open; these contexts are neither hidden nor treated as established new defects.
|
||||
|
||||
History retains meaningful generic-locale unit6PASS/2FAIL and native10PASS/1FAIL helper contrast RED, as well as raw-expanded0/1 RED (one4.48:1 helper contrast failure) before the final scoped correction. Earlier13-capture PASS with a collapsed raw panel remains coverage-limited. Old0UBD12-state source/export/results are retained consistently under `attempt3-20261012T110336Z`, with original metadata bytes and explicit reference-prefix relocation; the final W4 capsule uses `final-expanded`, so old source/export hashes are never relabelled current. The original DwxCC export retention gap above remains. Invalid runner aliases asserted before launch; an initially wrong unit selector produced46/3 and was corrected to54/4. These tooling/selection corrections add no skipped cases or passing scope. Root and this agent independently verified **290 references/197 unique files,75 exported files and eight current source/test files**; the checker's Windows-path ordering correction changed no measured bytes.
|
||||
|
||||
**Structured-editor implemented scope CONDITIONAL GO; final staged hygiene PASS, normal commit/push PENDING.** Import review/history presentation remains the separate approved next slice; whole P7-03 is unfinished. RD21 stale-save/idempotency, old orphaned override recovery, M01–M12/manual/provider/output requirements, deferred M13/P5-13 and production NO-GO remain. All57 PRES statuses remain56 PARTIAL/C11 DEFERRED and32 routes retained. No Phase8 implementation or main push follows.
|
||||
|
||||
[Final staged editor static checks](evidence/editor-static-checks.json) passed2026-10-12 11:30:01–11:30:04UTC: all four secret/working/staged/entry-whitespace commands exit0;10 Markdown documents/384 local links checked with0errors,57 qualified groups/32 routes retained and main unchanged. Canonical UTF-8/LF SHA256 is `18cb0a46e035d6af96b1489509aab0f5979db2a81b635df164bb9759a33b23c5`. Exact JSON and four raw logs are frozen separately in `final-expanded/static/20261012T113001Z`; no new runtime or human acceptance follows. Normal commit/push is pending its actual receipt.
|
||||
|
||||
## Approved presentation boundaries
|
||||
|
||||
| Boundary | Existing owners and bounded change | Required finite acceptance |
|
||||
@@ -57,4 +99,4 @@ Existing [profile tests](../../../../job-tracker-ui/src/profile-page.test.tsx),
|
||||
|
||||
C01–C04/E08/E09 and relevant C07/W07 frozen/override boundaries remain qualified in the [matrix](../PRESERVATION.md). Record raw commands/results/attempts, measured source/build/export identity and every axe violation/incomplete category/context; retain full vectors in hashed immutable reports. Native emulation and finite images do not supply human screen-reader, zoom/reflow, physical touch/software-keyboard, real provider, production-font/photo or deployed-output acceptance. Density is tested only where an actual control applies.
|
||||
|
||||
The source-ID prerequisite has bounded runtime/readback GO; local repair3047b6c4 is unpushed; corrected full staged recheck PASS, normal hygiene successor/push pending. Structured-editor and import/history presentation remain **PENDING**. [Debt](../DEBT.md) retains RD21 separately, M01–M12 manual requirements, deferred M13/P5-13, optional P5-05/`followMode`, production NO-GO and Phase8 separation. Conditional development entry authorizes independent bounded work; it does not declare these gates complete.
|
||||
The source-ID prerequisite has bounded runtime/readback GO; repair3047b6c4 plus hygiene704637a are normally pushed/directly verified; original whitespace failure and exact snapshot relation remain recorded. Structured-editor implemented scope is CONDITIONAL GO with final staged hygiene PASS/normal push pending; import/history presentation remains **PENDING**. [Debt](../DEBT.md) retains RD21 separately, M01–M12 manual requirements, deferred M13/P5-13, optional P5-05/`followMode`, production NO-GO and Phase8 separation. Conditional development entry authorizes independent bounded work; it does not declare these gates complete.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-03",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-12T11:30:04.864682+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-12T11:30:01.781304+00:00",
|
||||
"finishedUtc": "2026-10-12T11:30:04.235080+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T113001Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T11:30:04.235080+00:00",
|
||||
"finishedUtc": "2026-10-12T11:30:04.323058+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T113001Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T11:30:04.323058+00:00",
|
||||
"finishedUtc": "2026-10-12T11:30:04.415537+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T113001Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-12T11:30:04.416038+00:00",
|
||||
"finishedUtc": "2026-10-12T11:30:04.543148+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T113001Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 10,
|
||||
"relativeLinks": 384,
|
||||
"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."
|
||||
]
|
||||
}
|
||||
@@ -105,5 +105,148 @@
|
||||
"Structured editor presentation",
|
||||
"Import review/history presentation"
|
||||
],
|
||||
"manualQualifications": "M01–M12 remain unverified; M09/M11 contextual/reader, physical/device/zoom/provider/output and production gates carry; M13/P5-13 deferred, Phase8 separate."
|
||||
"manualQualifications": "M01–M12 remain unverified; M09/M11 contextual/reader, physical/device/zoom/provider/output and production gates carry; M13/P5-13 deferred, Phase8 separate.",
|
||||
"structuredEditorEntry": {
|
||||
"commit": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"pushReceipt": {
|
||||
"observedUtc": "2026-10-12T06:34:48.620963+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"sourceRepairCommit": "3047b6c4a87c43de6f397e4a3d109be9ce3ddeca",
|
||||
"hygieneCommit": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"head": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"originDevelopment": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"liveDevelopment": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreePorcelain": "",
|
||||
"push": "normal; both new commits; no force/main push"
|
||||
},
|
||||
"repairManifestSnapshot": {
|
||||
"path": ".artifacts/phase7/p7-03/editor/baseline/entry-source-id-results.json",
|
||||
"sha256": "fdde360d26f9394ee5d265ba9ac68ec65405a6946c010d5b7b004c9a380ca595",
|
||||
"bytes": 102346,
|
||||
"hashPolicy": "Exact saved raw bytes"
|
||||
},
|
||||
"sourceBaseline": {
|
||||
"path": ".artifacts/phase7/p7-03/editor/baseline/identity.json",
|
||||
"sha256": "9ee021b5d7fb36c7ad4985fe81106f98a55c09f2b569d4cb735c5410d829ef96",
|
||||
"bytes": 3702,
|
||||
"hashPolicy": "Exact saved raw bytes"
|
||||
},
|
||||
"latestFullFrontend": "573/89 inherited",
|
||||
"derivedOnly": {
|
||||
"frontend": "618/90",
|
||||
"browser": 216,
|
||||
"backend": 1034
|
||||
},
|
||||
"freshPriorSlice": "Source-ID affected46/3,native2/preservation2,types/lint/build/corrected hygiene; no new editor execution",
|
||||
"catalogBoundary": "Committed704637a catalog frozen from Git object; current expected bilingual test catalog differs, while native RED uses old prebuilt bzQi labels/H3.",
|
||||
"status": "EDITOR_IMPLEMENTED_SCOPE_CONDITIONAL_GO;FINAL_STAGED_HYGIENE_PASS;NORMAL_PUSH_PENDING;IMPORT_HISTORY_PENDING",
|
||||
"firstEditorRed": {
|
||||
"path": ".artifacts/phase7/p7-03/editor/baseline/native-first/baseline-results.json",
|
||||
"sha256": "2f369a739c9241b0aa6e823a52fb454e9b35e8506ccecdc3cc664edc8afe3104",
|
||||
"bytes": 766463,
|
||||
"hashPolicy": "Exact saved raw bytes",
|
||||
"native": {
|
||||
"passed": 0,
|
||||
"failed": 11,
|
||||
"skipped": 0,
|
||||
"flaky": 0,
|
||||
"retries": 0
|
||||
},
|
||||
"affectedUnit": {
|
||||
"passed": 38,
|
||||
"failed": 8,
|
||||
"total": 46,
|
||||
"suites": 3,
|
||||
"pending": 0
|
||||
},
|
||||
"axe": {
|
||||
"scans": 12,
|
||||
"violationNodes": 0,
|
||||
"incompleteNodes": 408,
|
||||
"backgroundOverlapChecks": 396,
|
||||
"shortTextChecks": 12
|
||||
},
|
||||
"qualification": "Unchanged prebuilt bzQi editor/704637a old catalog versus newly materialized expected test catalog; full vectors and sources retained in immutable baseline. Only320 NB dark image inspected by parent; overflow owner diagnostic and final acceptance pending."
|
||||
},
|
||||
"narrowDiagnostic": {
|
||||
"path": ".artifacts/phase7/p7-03/editor/baseline/native-narrow/diagnostic-results.json",
|
||||
"sha256": "7ea08bda9bc1f64b4e10ebdbe3aadbf329703c84d7e9d639a93b93cea526c9d5",
|
||||
"bytes": 96396,
|
||||
"hashPolicy": "Exact saved raw bytes",
|
||||
"native": {
|
||||
"passed": 0,
|
||||
"failed": 1,
|
||||
"skipped": 0,
|
||||
"flaky": 0,
|
||||
"retries": 0
|
||||
},
|
||||
"overlap": "Same320 NB dark case from original11; no added case count",
|
||||
"geometry": {
|
||||
"rootOverflow": 30,
|
||||
"overflowElements": 20,
|
||||
"maximumOuterRight": 349.6875,
|
||||
"maximumVisibleFieldRight": 314.6875
|
||||
},
|
||||
"axe": {
|
||||
"scans": 1,
|
||||
"violationNodes": 0,
|
||||
"incompleteNodes": 37
|
||||
},
|
||||
"qualification": "Native DOM diagnostic only; no source cause/repair or individual PNG inspection inferred"
|
||||
},
|
||||
"candidateUnitGreen": {
|
||||
"path": ".artifacts/phase7/p7-03/editor/candidate-20261012T104643Z/candidate-results.json",
|
||||
"sha256": "fc802b0e2804598ca8d229f71066222b2906f1c9b4828802ede45c87f8ccbe96",
|
||||
"bytes": 17344,
|
||||
"hashPolicy": "Exact saved raw bytes",
|
||||
"unit": {
|
||||
"passed": 54,
|
||||
"failed": 0,
|
||||
"pending": 0,
|
||||
"suites": 4
|
||||
},
|
||||
"appTypecheck": "PASS10:46:21-10:46:32UTC",
|
||||
"status": "CANDIDATE_ONLY; final native/build/visual/static/push pending",
|
||||
"derivedFrontend": "626/91 only; no full execution"
|
||||
},
|
||||
"candidateNativeAttempt2": {
|
||||
"path": ".artifacts/phase7/p7-03/editor/attempt2-20261012T105446Z/attempt-results.json",
|
||||
"sha256": "03f504f04b0d03a2636918ffe1b5e0af67b462a994f19a74cf0f0b0255f8dfc9",
|
||||
"bytes": 242474,
|
||||
"hashPolicy": "Exact saved raw bytes",
|
||||
"runtime": "29finite native PASS; overlapping editor11/sourceID2/Career14/preservation2",
|
||||
"status": "ACCEPTANCE_WITHHELD;helper contrast and generic-error locale repair pending",
|
||||
"editorAxe": {
|
||||
"scans": 12,
|
||||
"violationNodes": 7,
|
||||
"incompleteNodes": 374
|
||||
},
|
||||
"visualReview": "Only NB320/EN360/EN1920 images actually reviewed; other9 not claimed",
|
||||
"derivedOnly": {
|
||||
"frontend": "626/91",
|
||||
"browser": 227
|
||||
}
|
||||
},
|
||||
"latestEditorResult": {
|
||||
"path": "docs/redesign/phase-7/p7-03/evidence/editor-results.json",
|
||||
"sha256": "b1075e97a9cd705552b82b94782a7411d21d8700a5239a5c8d50bc84a193cef4",
|
||||
"bytes": 255840,
|
||||
"hashPolicy": "UTF-8 text CRLF normalized to LF",
|
||||
"unit": "54/4 fresh PASS",
|
||||
"native": "editor11/source-ID2/Career14/preservation2 PASS;29 finite overlapping ordinary cases",
|
||||
"buildId": "W4fUO7_GUC0Qh5FuL3qVL",
|
||||
"visual": "All13 final PNGs actually reviewed;human/device/zoom/provider qualification pending",
|
||||
"axe": "Editor13 scans0violation nodes/377 incompletes;Career22 scans0/99 separately;source-ID/preservation0axe attachments",
|
||||
"derivedOnly": {
|
||||
"frontend": "626/91",
|
||||
"browser": 227,
|
||||
"backend": 1034
|
||||
},
|
||||
"qualification": "Earlier entry/baseline/candidate observations remain historical. No new whole-suite/backend/manual/production acceptance;all57 statuses and32 routes unchanged.",
|
||||
"staticGate": "PASS2026-10-12T11:30:04UTC;four commands0,10docs384links0errors,57groups32routes retained;main unchanged"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-03",
|
||||
"slice": "Separate source-ID and nullable-metadata prerequisite repair",
|
||||
"collectedUtc": "2026-10-12T06:16:28.371706+00:00",
|
||||
"acceptance": "GO for bounded runtime compatibility repair; corrected staged hygiene PASS, normal hygiene successor and push pending",
|
||||
"acceptance": "GO for bounded forward compatibility repair; corrected hygiene PASS, normal two-commit push directly verified",
|
||||
"entryCommit": "8b4d5c21125e800199d3ecc2d9c059a80ac5125d",
|
||||
"entrySnapshot": {
|
||||
"path": ".artifacts/phase7/p7-03/source-id-repair/final/entry-career-results.json",
|
||||
@@ -50,10 +50,11 @@
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"canonicalLf": {
|
||||
"path": "job-tracker-ui/src/profile-page.test.tsx",
|
||||
"path": ".artifacts/phase7/p7-03/source-id-repair/final/tests/job-tracker-ui/src/profile-page.test.tsx",
|
||||
"sha256": "ac1421ecf0fb4723fa974e4e1f0899d0eb0aa53d3225eb428b11c086d6f22950",
|
||||
"bytes": 30021,
|
||||
"hashPolicy": "UTF-8 text with CRLF normalized to LF"
|
||||
"hashPolicy": "UTF-8 text with CRLF normalized to LF",
|
||||
"originalSourcePath": "job-tracker-ui/src/profile-page.test.tsx"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -65,10 +66,11 @@
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"canonicalLf": {
|
||||
"path": "job-tracker-ui/e2e/phase7-career-source-ids.spec.ts",
|
||||
"path": ".artifacts/phase7/p7-03/source-id-repair/final/tests/job-tracker-ui/e2e/phase7-career-source-ids.spec.ts",
|
||||
"sha256": "92bc8f1b4c82210207071c56940813dfece3136832ba09ebd1e6c3471d8d7945",
|
||||
"bytes": 20601,
|
||||
"hashPolicy": "UTF-8 text with CRLF normalized to LF"
|
||||
"hashPolicy": "UTF-8 text with CRLF normalized to LF",
|
||||
"originalSourcePath": "job-tracker-ui/e2e/phase7-career-source-ids.spec.ts"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -2042,8 +2044,30 @@
|
||||
"M01–M12 human/device/zoom/contextual requirements carry; P5-13/C11 deferred, Phase8 separate, main unchanged/production NO-GO."
|
||||
],
|
||||
"pushReceipt": {
|
||||
"status": "LOCAL_REPAIR_COMMITTED_UNPUSHED_PENDING_HYGIENE_SUCCESSOR",
|
||||
"repairCommit": "3047b6c4a87c43de6f397e4a3d109be9ce3ddeca"
|
||||
"status": "PUSHED_AND_DIRECTLY_VERIFIED",
|
||||
"repairCommit": "3047b6c4a87c43de6f397e4a3d109be9ce3ddeca",
|
||||
"hygieneCommit": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"record": {
|
||||
"observedUtc": "2026-10-12T06:34:48.620963+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"sourceRepairCommit": "3047b6c4a87c43de6f397e4a3d109be9ce3ddeca",
|
||||
"hygieneCommit": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"head": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"originDevelopment": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"liveDevelopment": "704637ab375c32d14aa46f9d4ef0f210136de4d9",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreePorcelain": "",
|
||||
"push": "normal; both new commits; no force/main push"
|
||||
},
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-03/source-id-repair/push-receipt.json",
|
||||
"sha256": "fa8ae1e5170110070e4ca02507517628fce8b5aed1512a6ef44e1d76275a15dd",
|
||||
"bytes": 687,
|
||||
"hashPolicy": "Exact saved raw bytes"
|
||||
},
|
||||
"qualification": "Actual normal chronological two-commit push; no rewrite/force/main push. Measured runtime/test source3047 and the one-LF hygiene relation remain historical and unchanged."
|
||||
},
|
||||
"readbackPayloads": [
|
||||
{
|
||||
@@ -2286,9 +2310,9 @@
|
||||
"qualification": "Earlier static observation PASS06:21:53; subsequent final staged check failed and the root committed without handling it. Current repository hygiene is separately pending correction recheck in repositoryHygiene; prior measured static/runtime records remain unchanged."
|
||||
},
|
||||
"repositoryHygiene": {
|
||||
"status": "CORRECTED_FULL_STAGED_GATE_PASS_PENDING_SUCCESSOR_AND_PUSH",
|
||||
"status": "CORRECTED_SUCCESSOR_COMMITTED_AND_NORMAL_PUSH_VERIFIED",
|
||||
"repairCommit": "3047b6c4a87c43de6f397e4a3d109be9ce3ddeca",
|
||||
"pushStatus": "UNPUSHED",
|
||||
"pushStatus": "PUSHED_AND_DIRECTLY_VERIFIED",
|
||||
"reportedOriginalStagedCheckExitCode": 1,
|
||||
"historicalCommittedCheck": {
|
||||
"record": {
|
||||
@@ -2498,7 +2522,8 @@
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
},
|
||||
"qualification": "Fresh actual staged correction gate PASS; original failure/local3047/as-run snapshots remain immutable. Normal hygiene successor commit and subsequent normal two-commit push pending."
|
||||
}
|
||||
"qualification": "Fresh actual staged correction gate PASS; subsequent normal hygiene successor704637a and two-commit push directly verified. Original failure/local3047/as-run snapshots remain immutable."
|
||||
},
|
||||
"hygieneCommit": "704637ab375c32d14aa46f9d4ef0f210136de4d9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-00 fresh scope inventory and unchanged-source fixture baseline; finite static checks pass. Commit and remote receipt in the Phase 7 ledger.
|
||||
- [x] P7-01 public authentication and separate account/security presentation (implemented scope CONDITIONAL GO; account pushedad7b419/direct receipt19:16:15UTC): public sub-slice CONDITIONAL GO, pushed `8ad720d`, direct remote=HEAD verified2026-10-11 18:50:24UTC, main unchanged; affected61/native22/isolated SMTP2/helper6/auth subset87 pass. [Checkpoint record](../redesign/phase-7/p7-01/README.md) retains manual/provider/production gates. Fresh account full573/89, native6/public22/auth-boundaries10 and types/lint/build pass; all eight final post-copy PNGs actually inspected and source/context evidence frozen. Account85 and public31 current axe incompletes remain qualified; final staged finite hygiene passed; normal account pushad7b419 directly verified2026-10-11 19:16:15UTC with localHEAD=origin=live equality and clean worktree at observation, main unchanged.
|
||||
- [x] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push8b4d5c2 directly verified05:51:04UTC, localHEAD=origin=live/clean worktree/main unchanged. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
|
||||
- [ ] P7-03 Career structured editor and import/history: P7-02 pushed8b4d5c2; bounded source-ID/null-metadata repair46/3+native2/preservation2/types/lint/build and readback review pass; local3047b6c4 unpushed after staged EOF whitespace failure, corrected full staged gate PASS (four commands0,10docs374links0errors), normal hygiene successor/two-commit push pending. No logic/assertion change; frozen runtime preserved. Old orphan recovery/concurrency remains open; prove dirty-locale/held-PUT pending-write ownership/recovery before structured/import presentation. Full618/90/browser216 derived only.
|
||||
- [ ] P7-03 Career structured editor and import/history: prerequisite repair3047/hygiene704637a normally pushed/directly verified06:34:48UTC, original staged EOF failure retained. Structured-editor implemented scope CONDITIONAL GO: affected54/4, native editor11/source-ID2/Career14/preservation2, types/lint/build PASS; all13 final PNGs inspected,13 editor scans0/377 and separate Career22 scans0/99 retained. Dirty-locale/no-refetch, structured/raw held-save locks, retry/validation/readback and finite reflow proven. Final staged hygiene/normal push PENDING; separate import/history presentation and whole checkpoint remain unfinished. Old orphan recovery/concurrency/manual/provider/production gates stay open. Full626/91/browser227/backend1,034 derived only; latest full573/89 inherited,57/32 unchanged/main NO-GO.
|
||||
- [ ] P7-04 CV library and public entry.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders.
|
||||
- [ ] P7-06 remaining Application forms and global correspondence.
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { StructuredCvProfile } from '../src/profileCv';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null };
|
||||
|
||||
async function json<T>(response: APIResponse, status = 200): Promise<T> {
|
||||
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
||||
expect(csrf).toBeDefined();
|
||||
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
|
||||
const test = base.extend<{ owner: Owner }>({
|
||||
owner: async ({ page, context, playwright }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminOwner = await login(admin, 'e2e@example.test');
|
||||
const email = `p7-career-editor-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const owner = await login(page.request, email);
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
|
||||
? route.continue() : route.abort();
|
||||
});
|
||||
await provide(owner);
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
async function seed(page: Page, owner: Owner) {
|
||||
const profile = {
|
||||
version: '1',
|
||||
metadata: { fields: { 'contact.fullName': { confidence: null, sourcePage: null, method: 'manual',
|
||||
sourceSnippet: 'A long owner-reviewed source note about recorded work and qualifications, retained without adding a new claim.', reviewState: 'reviewed' } } },
|
||||
contact: { fullName: 'Åse Nordlys — owner-reviewed professional identity with an intentionally long recorded name',
|
||||
headline: 'Experienced software engineer with recorded delivery, collaboration and professional responsibility across Norwegian organisations',
|
||||
email: 'owner-reviewed-professional-contact@example.test', phone: '+47 900 00 001', location: 'Trondheim, Trøndelag, Norge — remote and hybrid work',
|
||||
website: 'https://example.test/portfolio/recorded-professional-work', linkedIn: 'https://www.linkedin.com/in/reviewed-professional-identity',
|
||||
gitHub: 'https://github.com/reviewed-professional-identity', links: [{ label: 'Detailed portfolio of recorded work and owner-reviewed outcomes',
|
||||
url: 'https://example.test/recorded-work/long-reviewed-professional-source-reference' }] },
|
||||
summary: ['Owner-reviewed professional summary with specific recorded delivery and collaboration.', 'Another factual summary line, kept separate.'],
|
||||
jobs: [{ id: 'editor-job-source', title: 'Senior software engineer with responsibility for recorded platform delivery and professional collaboration',
|
||||
company: 'Nordlys programvare og dokumenterte tjenester AS', location: 'Trondheim and remote collaboration across Norway',
|
||||
start: 'Jan 2020', end: 'Dec 2022', bullets: ['Recorded delivery with owner-reviewed implementation details.', 'Recorded collaboration and operational responsibility.'], skills: ['C#', 'React', 'Norsk'] }],
|
||||
education: [{ id: 'editor-education-source', qualification: 'Computer science and software engineering with a recorded specialisation in reliable services',
|
||||
qualificationLevel: 'Bachelor', institution: 'Recorded university department for computer science and software engineering',
|
||||
location: 'Trondheim, Norge', start: 'Aug 2016', end: 'Jun 2019', details: ['Recorded qualification and reviewed project work.'] }],
|
||||
projects: [{ id: 'editor-project-source', name: 'Recorded professional project', role: 'Developer', start: '2023-01', end: '2023-12',
|
||||
bullets: ['Recorded project evidence.'], skills: ['React'] }],
|
||||
certifications: [{ id: 'editor-certification-source', name: 'Recorded certification', issuer: 'Fixture Academy', date: 'Jun 2022', details: ['Reviewed certification detail.'] }],
|
||||
skills: ['C#', 'React', 'SQL', 'Norsk'], interests: ['Recorded community participation'],
|
||||
languages: [{ name: 'Norwegian Sign Language — recorded professional communication experience', level: 'Professional working proficiency with reviewed workplace communication',
|
||||
notes: 'An owner-reviewed language note retained with the source and kept separate from CV-specific presentation.' }],
|
||||
awards: ['Recorded award'], publications: ['Recorded publication'], organisations: ['Recorded organisation'], references: ['Available by owner agreement'],
|
||||
otherSections: [{ title: 'Owner-reviewed custom career information with a deliberately long descriptive section title',
|
||||
items: ['Recorded custom fact with relevant professional context.', 'Another recorded custom fact, preserved during unrelated contact editing.'] }],
|
||||
};
|
||||
const raw = 'Exact original career source — æøå\nRecorded work and qualifications.\n';
|
||||
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } }));
|
||||
return { canonical: await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`)),
|
||||
history: await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`)) };
|
||||
}
|
||||
|
||||
async function enter(page: Page, owner: Owner, sample: Sample) {
|
||||
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 }) => {
|
||||
localStorage.setItem('uiLanguage', language);
|
||||
localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/career?section=profile&source=phase7-editor#preserved');
|
||||
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.getByLabel(translations[sample.language].profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 001');
|
||||
}
|
||||
|
||||
async function navigateSection(page: Page, language: Sample['language'], section: 'profile' | 'import') {
|
||||
const t = translations[language];
|
||||
const label = section === 'profile' ? t.careerWorkspaceProfileTab : t.careerWorkspaceImportTab;
|
||||
const selector = page.getByRole('combobox', { name: t.careerWorkspaceNavigation, exact: true });
|
||||
if (await selector.isVisible()) {
|
||||
await selector.click();
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
} else await page.getByRole('tab', { name: label, exact: true }).click();
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('section')).toBe(section);
|
||||
expect(url.searchParams.get('source')).toBe('phase7-editor');
|
||||
expect(url.hash).toBe('#preserved');
|
||||
}
|
||||
|
||||
function escaped(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||||
|
||||
async function openRaw(page: Page, language: Sample['language']) {
|
||||
const field = page.getByLabel(translations[language].profileCvTextLabel, { exact: true });
|
||||
const disclosure = page.getByRole('button', { name: new RegExp(escaped(translations[language].profileCvRawPanelTitle)) });
|
||||
if (await disclosure.getAttribute('aria-expanded') !== 'true') await disclosure.click();
|
||||
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(field).toBeVisible();
|
||||
await field.scrollIntoViewIfNeeded();
|
||||
await expect(field).toBeInViewport({ ratio: 0.5 });
|
||||
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
|
||||
return field;
|
||||
}
|
||||
|
||||
async function settle(page: Page) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
||||
await settle(page);
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
|
||||
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
overflowElements: [...document.querySelectorAll('body *')]
|
||||
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
|
||||
.map(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return { tag: element.tagName.toLowerCase(), id: element.id || null, class: element.getAttribute('class'),
|
||||
text: (element.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120), left: rect.left, right: rect.right, width: rect.width,
|
||||
minWidth: style.minWidth, display: style.display, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth };
|
||||
})
|
||||
.filter(element => element.width > 0 && (element.right > innerWidth + 1 || element.left < -1))
|
||||
.slice(0, 20),
|
||||
visibleFields: [...document.querySelectorAll('#career-profile-editor input, #career-profile-editor textarea, #career-cv-import input, #career-cv-import textarea')]
|
||||
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && element.getAttribute('aria-hidden') !== 'true')
|
||||
.map(element => { const rect = element.getBoundingClientRect(); return { name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent,
|
||||
left: rect.left, right: rect.right, width: rect.width }; }),
|
||||
}));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-career-editor-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.getByRole('main').ariaSnapshot(),
|
||||
axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete,
|
||||
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Automated Chromium evidence only; no human screen reader or physical touch/software keyboard',
|
||||
'No actual browser zoom, production font/photo, PDF, model/provider or production acceptance'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow).toBe(0);
|
||||
expect(geometry.visibleFields.length).toBeGreaterThan(0);
|
||||
if (state === 'held-save-raw-import') expect(geometry.visibleFields.some(field => field.name === translations[sample.language].profileCvTextLabel)).toBe(true);
|
||||
expect(geometry.visibleFields.every(field => field.left >= 0 && field.right <= geometry.width && field.width > 0)).toBe(true);
|
||||
expect(axe.violations).toEqual([]);
|
||||
return axe;
|
||||
}
|
||||
|
||||
test('[P7-03 PRES-C01 C02 E08] structured and raw drafts survive English to Bokmål without canonical refetch or write', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const before = await seed(page, owner);
|
||||
const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
let profileReads = 0;
|
||||
const readCompletions: Promise<void>[] = [];
|
||||
await page.route('**/api/career/profile', async route => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
profileReads++;
|
||||
const completed = (async () => { const response = await route.fetch(); await route.fulfill({ response }); })();
|
||||
readCompletions.push(completed);
|
||||
await completed;
|
||||
});
|
||||
const canonicalWrites: unknown[] = [];
|
||||
page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') canonicalWrites.push(request.postDataJSON()); });
|
||||
await enter(page, owner, sample);
|
||||
const draftPhone = '+47 900 00 099';
|
||||
const draftRaw = 'Unsaved owner-reviewed raw draft — æøå\nThis text is not persisted yet.\n';
|
||||
await page.getByLabel(translations.en.profileCvContactPhone, { exact: true }).fill(draftPhone);
|
||||
await navigateSection(page, 'en', 'import');
|
||||
await (await openRaw(page, 'en')).fill(draftRaw);
|
||||
await navigateSection(page, 'en', 'profile');
|
||||
await expect(page.getByLabel(translations.en.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone);
|
||||
const readsBeforeLocale = profileReads;
|
||||
const preferenceSaved = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
|
||||
await page.locator('header').getByRole('button', { name: 'Norsk', exact: true }).click();
|
||||
expect((await preferenceSaved).status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
await expect(page.getByLabel(translations.nb.profileCvContactPhone, { exact: true })).toBeVisible();
|
||||
await settle(page);
|
||||
await Promise.all(readCompletions);
|
||||
await settle(page);
|
||||
const observedPhone = await page.getByLabel(translations.nb.profileCvContactPhone, { exact: true }).inputValue();
|
||||
await navigateSection(page, 'nb', 'import');
|
||||
const observedRaw = await (await openRaw(page, 'nb')).inputValue();
|
||||
const after = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
||||
const historyAfter = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
||||
await info.attach('native-career-editor-locale-drafts', { body: JSON.stringify({ readsBeforeLocale, readsAfterLocale: profileReads,
|
||||
expected: { phone: draftPhone, raw: draftRaw }, observed: { phone: observedPhone, raw: observedRaw }, canonicalWrites, before, after, historyAfter,
|
||||
qualification: 'Preference PUT204 is real; canonical GETs are proxied to actual HTTP for completion tracking. No source response is fabricated.' }), contentType: 'application/json' });
|
||||
await capture(page, info, { ...sample, language: 'nb' }, 'locale-retained-raw-draft');
|
||||
expect(readsBeforeLocale).toBeGreaterThan(0);
|
||||
expect(profileReads).toBe(readsBeforeLocale);
|
||||
expect(observedPhone).toBe(draftPhone);
|
||||
expect(observedRaw).toBe(draftRaw);
|
||||
expect(canonicalWrites).toEqual([]);
|
||||
expect(after).toEqual(before.canonical);
|
||||
expect(historyAfter).toEqual(before.history);
|
||||
});
|
||||
|
||||
test('[P7-03 PRES-C01 C02 E08] held save locks edits and duplicate writes; failed save retains its snapshot for explicit identical-body retry', async ({ page, owner }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const before = await seed(page, owner);
|
||||
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
|
||||
const t = translations.en;
|
||||
const payloads: unknown[] = [];
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route('**/api/career/profile', async route => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
payloads.push(route.request().postDataJSON());
|
||||
if (payloads.length === 1) {
|
||||
await held;
|
||||
return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled career save unavailable' }) });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await enter(page, owner, sample);
|
||||
const draftPhone = '+47 900 00 088';
|
||||
const draftSummary = 'Owner-reviewed pending summary.';
|
||||
const draftRaw = 'Pending owner raw source — æøå\nRetain this exact draft after failure.\n';
|
||||
await page.getByLabel(t.profileCvContactPhone, { exact: true }).fill(draftPhone);
|
||||
const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
|
||||
await summary.fill(draftSummary);
|
||||
await navigateSection(page, 'en', 'import');
|
||||
await (await openRaw(page, 'en')).fill(draftRaw);
|
||||
await navigateSection(page, 'en', 'profile');
|
||||
await summary.evaluate((element: HTMLTextAreaElement) => element.setSelectionRange(0, 5));
|
||||
const save = page.getByRole('button', { name: new RegExp(`^(?:${escaped(t.profileSaveChanges)}|${escaped(t.saving)})$`) });
|
||||
const firstResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
||||
let pending!: { fields: Array<{ name: string | null; disabled: boolean }>; actions: Array<{ name: string; disabled: boolean }>;
|
||||
rawDisabled: boolean; saveDisabled: boolean; savingLabelCount: number; writeCount: number; summaryAfterAttempt: string };
|
||||
try {
|
||||
await save.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect.poll(() => payloads.length).toBe(1);
|
||||
await settle(page);
|
||||
const editor = page.locator('#career-profile-editor');
|
||||
const actionNames = [t.profileCvAddLink, t.profileCvStructuredAddLanguage, t.profileCvStructuredAddJob, t.profileCvStructuredAddEducation,
|
||||
t.profileCvStructuredAddOtherSection, t.profileCvStructuredRemove, t.profileCvDeleteCustomLink.replace('{index}', '1'),
|
||||
t.careerProfileRemoveJob.replace('{index}', '1'), t.careerProfileRemoveEducation.replace('{index}', '1'),
|
||||
t.careerProfileRemoveLanguage.replace('{index}', '1'), t.careerProfileRemoveOtherSection.replace('{index}', '1'),
|
||||
t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink];
|
||||
const fields = await editor.locator('input:not([type="file"]),textarea:not([aria-hidden="true"])').evaluateAll(elements => elements
|
||||
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden')
|
||||
.map(element => ({ name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent || null,
|
||||
disabled: (element as HTMLInputElement).disabled })));
|
||||
const actions = (await editor.getByRole('button').evaluateAll(elements => elements.map(element => ({
|
||||
name: element.getAttribute('aria-label') || (element.textContent ?? '').replace(/\s+/g, ' ').trim(), disabled: (element as HTMLButtonElement).disabled,
|
||||
})))).filter(action => actionNames.includes(action.name));
|
||||
const rawDisabled = await page.getByLabel(t.profileCvTextLabel, { exact: true }).isDisabled();
|
||||
const saveDisabled = await save.isDisabled();
|
||||
const savingLabelCount = await page.getByRole('button', { name: t.saving, exact: true }).count();
|
||||
// A native disabled button ignores click(); an enabled formatting button would mutate
|
||||
// the held snapshot's draft. These attempts remain local to this disposable owner.
|
||||
await save.evaluate((element: HTMLButtonElement) => element.click());
|
||||
await editor.getByRole('button', { name: t.richTextBold, exact: true }).first().evaluate((element: HTMLButtonElement) => element.click());
|
||||
await settle(page);
|
||||
pending = { fields, actions, rawDisabled, saveDisabled, savingLabelCount, writeCount: payloads.length, summaryAfterAttempt: await summary.inputValue() };
|
||||
await info.attach('native-career-editor-pending-lock', { body: JSON.stringify({ pending, heldBody: payloads[0] }), contentType: 'application/json' });
|
||||
await capture(page, info, sample, 'held-save');
|
||||
await navigateSection(page, 'en', 'import');
|
||||
await expect(await openRaw(page, 'en')).toBeDisabled();
|
||||
try { await capture(page, info, sample, 'held-save-raw-import'); }
|
||||
finally { await navigateSection(page, 'en', 'profile'); }
|
||||
} finally { release(); }
|
||||
expect((await firstResponse).status()).toBe(500);
|
||||
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
|
||||
const failedPhone = await page.getByLabel(t.profileCvContactPhone, { exact: true }).inputValue();
|
||||
const failedSummary = await summary.inputValue();
|
||||
const failedRaw = await page.getByLabel(t.profileCvTextLabel, { exact: true }).inputValue();
|
||||
const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents();
|
||||
const afterFailure = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
||||
const historyAfterFailure = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
||||
await capture(page, info, sample, 'failed-save-retained-draft');
|
||||
const retryResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
expect((await retryResponse).status()).toBe(200);
|
||||
const afterRetry = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
||||
const historyAfterRetry = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
||||
await info.attach('native-career-editor-save-retry', { body: JSON.stringify({ pending: pending!, payloads, failedDraft: { phone: failedPhone, summary: failedSummary, raw: failedRaw },
|
||||
failureAlerts, before, afterFailure, historyAfterFailure, afterRetry, historyAfterRetry,
|
||||
qualification: 'First500 is controlled transport failure; retry200 and canonical/history readbacks use actual SQLite HTTP. No canonical stale-write protection is claimed.' }), contentType: 'application/json' });
|
||||
expect(pending!.fields.length).toBeGreaterThan(20);
|
||||
expect(pending!.fields.every(field => field.disabled)).toBe(true);
|
||||
expect(pending!.actions.length).toBeGreaterThanOrEqual(26);
|
||||
expect(pending!.actions.every(action => action.disabled)).toBe(true);
|
||||
expect(pending!.rawDisabled).toBe(true);
|
||||
expect(pending!.saveDisabled).toBe(true);
|
||||
expect(pending!.savingLabelCount).toBe(1);
|
||||
expect(pending!.writeCount).toBe(1);
|
||||
expect(pending!.summaryAfterAttempt).toBe(draftSummary);
|
||||
expect(failedPhone).toBe(draftPhone);
|
||||
expect(failedSummary).toBe(draftSummary);
|
||||
expect(failedRaw).toBe(draftRaw);
|
||||
expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('Controlled career save unavailable'))).toBe(true);
|
||||
expect(afterFailure).toEqual(before.canonical);
|
||||
expect(historyAfterFailure).toEqual(before.history);
|
||||
expect(payloads).toHaveLength(2);
|
||||
expect(payloads[1]).toEqual(payloads[0]);
|
||||
expect(afterRetry.profile.contact.phone).toBe(draftPhone);
|
||||
expect(afterRetry.profile.summary).toEqual([draftSummary]);
|
||||
expect(afterRetry.cvText).toBe(draftRaw);
|
||||
expect(historyAfterRetry).toHaveLength(before.history.length + 1);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone);
|
||||
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(draftSummary);
|
||||
});
|
||||
|
||||
test('[P7-03 PRES-C01 C02 E08] actual validation400 retains draft and source; corrected explicit save persists once', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const before = await seed(page, owner);
|
||||
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
|
||||
const t = translations.nb;
|
||||
const payloads: unknown[] = [];
|
||||
page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') payloads.push(request.postDataJSON()); });
|
||||
await enter(page, owner, sample);
|
||||
const website = page.getByLabel(t.profileCvContactWebsite, { exact: true });
|
||||
const prefix = 'https://example.test/';
|
||||
const overLimit = prefix + 'a'.repeat(501 - prefix.length);
|
||||
await website.fill(overLimit);
|
||||
const rejectedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const rejected = await rejectedResponse;
|
||||
const rejectedBody = await rejected.text();
|
||||
expect(rejected.status()).toBe(400);
|
||||
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
|
||||
const retainedWebsite = await website.inputValue();
|
||||
const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents();
|
||||
const afterFailure = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
||||
const historyAfterFailure = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
||||
await capture(page, info, sample, 'actual-validation400-retained-draft');
|
||||
const corrected = 'https://example.test/recorded-work/corrected-reviewed-reference';
|
||||
await website.fill(corrected);
|
||||
const acceptedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
expect((await acceptedResponse).status()).toBe(200);
|
||||
const afterSave = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
||||
const historyAfterSave = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
||||
await info.attach('native-career-editor-real-validation', { body: JSON.stringify({ rejectedStatus: rejected.status(), rejectedBody,
|
||||
requestedLength: overLimit.length, retainedWebsite, failureAlerts, payloads, before, afterFailure, historyAfterFailure, afterSave, historyAfterSave,
|
||||
qualification: 'The501-character website is rejected by the actual existing500-character short-field limit before persistence. No transport fault substitutes for this validation response.' }), contentType: 'application/json' });
|
||||
expect(rejectedBody).toContain('A contact field exceeds the allowed length.');
|
||||
expect(retainedWebsite).toBe(overLimit);
|
||||
expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('A contact field exceeds the allowed length.'))).toBe(true);
|
||||
expect(afterFailure).toEqual(before.canonical);
|
||||
expect(historyAfterFailure).toEqual(before.history);
|
||||
expect(payloads).toHaveLength(2);
|
||||
expect(afterSave.profile.contact.website).toBe(corrected);
|
||||
expect(afterSave.cvText).toBe(before.canonical.cvText);
|
||||
expect(historyAfterSave).toHaveLength(before.history.length + 1);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel(t.profileCvContactWebsite, { exact: true })).toHaveValue(corrected);
|
||||
});
|
||||
|
||||
const matrix: Sample[] = [
|
||||
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
|
||||
{ language: 'en', theme: 'light', width: 360, height: 800 },
|
||||
{ language: 'nb', theme: 'light', width: 390, height: 844 },
|
||||
{ language: 'en', theme: 'dark', width: 768, height: 1024 },
|
||||
{ language: 'nb', theme: 'dark', width: 1024, height: 768 },
|
||||
{ language: 'en', theme: 'light', width: 1280, height: 320 },
|
||||
{ language: 'nb', theme: 'light', width: 1440, height: 900 },
|
||||
{ language: 'en', theme: 'dark', width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
for (const sample of matrix) {
|
||||
test(`[P7-03 PRES-C01 C02 E08] structured editor headings, contextual actions and geometry ${sample.language}/${sample.theme}/${sample.width}x${sample.height}`, async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const before = await seed(page, owner);
|
||||
await enter(page, owner, sample);
|
||||
const t = translations[sample.language];
|
||||
const writes: string[] = [];
|
||||
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + request.url()); });
|
||||
// Capture the existing rendered baseline even when a new semantic requirement
|
||||
// fails in RED. This retains actual geometry, axe results and pixels for review.
|
||||
const axe = await capture(page, info, sample, 'structured-editor');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
await expect(page.getByRole('heading', { level: 2, name: t.profileCvStructuredEditor, exact: true })).toBeVisible();
|
||||
for (const label of [t.careerProfileContactSection, t.profileCvStructuredLanguages, t.profileCvStructuredJobs, t.profileCvStructuredEducation, t.profileCvStructuredOtherSections]) {
|
||||
await expect(page.getByRole('heading', { level: 3, name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
for (const label of [t.careerProfileRemoveLanguage, t.careerProfileRemoveJob, t.careerProfileRemoveEducation, t.careerProfileRemoveOtherSection]) {
|
||||
const remove = page.getByRole('button', { name: label.replace('{index}', '1'), exact: true });
|
||||
await expect(remove).toHaveCount(1);
|
||||
await remove.focus();
|
||||
await expect(remove).toBeFocused();
|
||||
}
|
||||
expect(axe.violations).toEqual([]);
|
||||
expect(await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`))).toEqual(before.canonical);
|
||||
expect(await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(before.history);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { emptyStructuredCv, StructuredCvProfile } from "./profileCv";
|
||||
import { ToastProvider } from "./toast";
|
||||
import CareerProfilePage from "./views/CareerProfilePage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage,
|
||||
api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn() },
|
||||
}));
|
||||
jest.mock("./components/CropImageDialog", () => () => null);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const en = translations.en;
|
||||
const nb = translations.nb;
|
||||
const rawText = "Original stored source\nNo silent import acceptance";
|
||||
let profile: StructuredCvProfile;
|
||||
let provider: "local" | "external";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function sourceResponse() {
|
||||
return { data: { profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } };
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
async function ready() {
|
||||
await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled());
|
||||
}
|
||||
|
||||
async function changeLocale() {
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
}
|
||||
|
||||
function readCount(url: string) {
|
||||
return mockedApi.get.mock.calls.filter(([requested]) => requested === url).length;
|
||||
}
|
||||
|
||||
function editor() {
|
||||
const element = document.getElementById("career-profile-editor");
|
||||
expect(element).not.toBeNull();
|
||||
return within(element!);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, "", "/career?section=profile&retained=1#editor");
|
||||
provider = "local";
|
||||
profile = {
|
||||
...emptyStructuredCv(),
|
||||
metadata: { profileVersion: 4, fields: {} },
|
||||
contact: {
|
||||
fullName: "Recorded owner", headline: "Backend engineer", email: "career@example.test",
|
||||
phone: "+47 12345678", location: "Oslo", website: "https://example.test",
|
||||
linkedIn: "https://example.test/linkedin", gitHub: "https://example.test/github",
|
||||
links: [{ label: "Portfolio", url: "https://example.test/portfolio" }],
|
||||
},
|
||||
summary: ["Recorded summary"], skills: ["C#", "SQL"], interests: ["Walking"],
|
||||
awards: ["Recorded award"], publications: ["Recorded publication"],
|
||||
organisations: ["Recorded organisation"], references: ["Recorded reference"],
|
||||
jobs: [{ id: "opaque-job-key", title: "Recorded role", company: "Recorded company", location: "Oslo", start: "2020", end: "Current", isCurrent: true, bullets: ["Recorded outcome"], skills: ["C#"] }],
|
||||
education: [{ id: "opaque-education-key", qualification: "Recorded degree", qualificationLevel: "Master", institution: "Recorded school", location: "Oslo", start: "2016", end: "2019", details: ["Recorded distinction"] }],
|
||||
certifications: [{ id: "opaque-certification-key", name: "Recorded certification", issuer: "Recorded issuer", date: "2024", details: ["Recorded credential detail"] }],
|
||||
projects: [{ id: "opaque-project-key", name: "Recorded project", role: "Owner", start: "2023", end: "2024", bullets: ["Recorded project outcome"], skills: ["SQL"] }],
|
||||
languages: [{ name: "English", level: "Professional", notes: "Recorded language note" }],
|
||||
otherSections: [{ title: "Volunteering", items: ["Recorded community work"] }],
|
||||
sections: [{ name: "Original source", content: "No silent import acceptance", wordCount: 5 }],
|
||||
};
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === "/career/profile") return Promise.resolve(sourceResponse() as any);
|
||||
if (url === "/auth/me") return Promise.resolve({ data: { id: "editor-owner", provider, email: "account@example.test", displayName: "Recorded owner" } } as any);
|
||||
if (url === "/career/profile/versions") return Promise.resolve({ data: [
|
||||
{ version: 4, source: "manual", createdAtUtc: "2026-10-12T01:00:00Z", isCurrent: true },
|
||||
{ version: 3, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false },
|
||||
] } as any);
|
||||
if (url === "/profile-cv/runs") return Promise.resolve({ data: [] } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
test("a locale update retains dirty structured and raw drafts without reading their source again", async () => {
|
||||
renderPage();
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Unsaved canonical owner" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Unsaved raw source" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
|
||||
const before = [readCount("/career/profile"), readCount("/auth/me"), readCount("/career/profile/versions"), readCount("/profile-cv/runs")];
|
||||
|
||||
await changeLocale();
|
||||
expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Unsaved canonical owner");
|
||||
expect([readCount("/career/profile"), readCount("/auth/me"), readCount("/career/profile/versions"), readCount("/profile-cv/runs")]).toEqual(before);
|
||||
fireEvent.click(screen.getByRole("tab", { name: nb.careerWorkspaceImportTab }));
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(nb.profileCvRawPanelTitle) }));
|
||||
expect(screen.getByLabelText(nb.profileCvTextLabel)).toHaveValue("Unsaved raw source");
|
||||
expect(window.location.search).toContain("retained=1");
|
||||
expect(window.location.hash).toBe("#editor");
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a pending initial read keeps one request and reports its failure in the latest locale without language writes", async () => {
|
||||
const pending = deferred<any>();
|
||||
const read = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/career/profile" ? pending.promise : read(url));
|
||||
renderPage();
|
||||
expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: en.saving })).not.toBeInTheDocument();
|
||||
await changeLocale();
|
||||
await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); });
|
||||
|
||||
expect(readCount("/career/profile")).toBe(1);
|
||||
expect(readCount("/auth/me")).toBe(1);
|
||||
const failure = await editor().findByRole("alert");
|
||||
expect(failure).toHaveTextContent(nb.profileLoadFailed);
|
||||
expect(failure).not.toHaveTextContent("Request failed with status code 500");
|
||||
expect(screen.queryByRole("button", { name: nb.saving })).not.toBeInTheDocument();
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })));
|
||||
expect(editor().getByRole("alert")).toHaveTextContent(en.profileLoadFailed);
|
||||
expect(editor().getByRole("alert")).not.toHaveTextContent(nb.profileLoadFailed);
|
||||
expect(readCount("/career/profile")).toBe(1);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a held Save locks every editor mutation, raw input and history restore until its snapshot settles", async () => {
|
||||
const pending = deferred<any>();
|
||||
mockedApi.put.mockReturnValue(pending.promise);
|
||||
renderPage();
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Snapshot owner" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Version history/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
|
||||
try {
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
const snapshot = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; cvText: string };
|
||||
expect(snapshot.profile.contact.fullName).toBe("Snapshot owner");
|
||||
expect(snapshot.cvText).toBe(rawText);
|
||||
expect(screen.getAllByRole("textbox").filter((field) => !(field as HTMLInputElement).disabled).map((field) => field.getAttribute("aria-label") || field.id)).toEqual([]);
|
||||
for (const name of [en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection, en.profileCvVersionRestore]) {
|
||||
expect(screen.getByRole("button", { name })).toBeDisabled();
|
||||
}
|
||||
for (const button of screen.getAllByRole("button", { name: /^(Remove|Delete custom link|Bold|Italic|Underline|Link$)/ })) expect(button).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: en.saving })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
|
||||
expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
|
||||
}
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
|
||||
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Snapshot owner");
|
||||
expect(screen.getByLabelText(en.profileCvContactFullName)).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("two Save activations before React commits pending state send only one canonical PUT", async () => {
|
||||
const pending = deferred<any>();
|
||||
mockedApi.put.mockReturnValue(pending.promise);
|
||||
renderPage();
|
||||
await ready();
|
||||
const save = screen.getByRole("button", { name: en.profileSaveChanges });
|
||||
try {
|
||||
act(() => {
|
||||
save.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
save.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed Save retains a scoped localized error and draft for retry through the same Save button", async () => {
|
||||
localStorage.setItem("uiLanguage", "nb");
|
||||
mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } });
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Recorded owner"));
|
||||
fireEvent.change(screen.getByLabelText(nb.profileCvContactFullName), { target: { value: "Beholdt utkast" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: nb.profileSaveChanges }));
|
||||
|
||||
const failure = await editor().findByRole("alert");
|
||||
expect(failure).toHaveTextContent(nb.profileUpdateFailed);
|
||||
expect(failure).not.toHaveTextContent("Request failed with status code 500");
|
||||
expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Beholdt utkast");
|
||||
expect(screen.getByText(nb.assetsUnsavedChanges)).toBeInTheDocument();
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })));
|
||||
expect(editor().getByRole("alert")).toHaveTextContent(en.profileUpdateFailed);
|
||||
expect(editor().getByRole("alert")).not.toHaveTextContent(nb.profileUpdateFailed);
|
||||
await changeLocale();
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
const save = screen.getByRole("button", { name: nb.profileSaveChanges });
|
||||
expect(save).toBeEnabled();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.put.mock.calls[1]).toEqual(mockedApi.put.mock.calls[0]);
|
||||
await waitFor(() => expect(editor().queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Beholdt utkast");
|
||||
expect(screen.queryByText(nb.assetsUnsavedChanges)).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the editor exposes named H2/H3 groups and contextual repeated-item remove controls", async () => {
|
||||
renderPage();
|
||||
await ready();
|
||||
expect(screen.getByRole("heading", { name: en.profileCvStructuredEditor, level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: en.profileCvStructuredEditor })).toBeInTheDocument();
|
||||
for (const name of [en.careerProfileContactSection, en.profileCvStructuredLanguages, en.profileCvStructuredJobs, en.profileCvStructuredEducation, en.profileCvStructuredOtherSections]) {
|
||||
expect(screen.getByRole("heading", { name, level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name })).toBeInTheDocument();
|
||||
}
|
||||
for (const name of [en.careerProfileRemoveJob, en.careerProfileRemoveEducation, en.careerProfileRemoveLanguage, en.careerProfileRemoveOtherSection]) {
|
||||
expect(screen.getByRole("button", { name: name.replace("{index}", "1") })).toBeEnabled();
|
||||
}
|
||||
expect(screen.getByRole("button", { name: en.profileCvDeleteCustomLink.replace("{index}", "1") })).toBeEnabled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("manual edits preserve long-tail data, custom links, opaque IDs and modeled fields without editor controls", async () => {
|
||||
renderPage();
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvStructuredAwards), { target: { value: "Recorded award\nManual award" } });
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvLinkLabel), { target: { value: "Edited portfolio" } });
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvJobTitle), { target: { value: "Edited role" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
|
||||
fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Manually retained source\nNo extraction requested" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
|
||||
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
|
||||
const saved = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; cvText: string };
|
||||
expect(saved).toEqual({ profile: {
|
||||
...profile,
|
||||
awards: ["Recorded award", "Manual award"],
|
||||
contact: { ...profile.contact, links: [{ label: "Edited portfolio", url: "https://example.test/portfolio" }] },
|
||||
jobs: [{ ...profile.jobs[0], title: "Edited role" }],
|
||||
}, cvText: "Manually retained source\nNo extraction requested" });
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a read-only provider cannot edit canonical fields or trigger canonical writes", async () => {
|
||||
provider = "external";
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Version history/ }));
|
||||
expect(screen.getAllByRole("textbox").filter((field) => !(field as HTMLInputElement).disabled)).toEqual([]);
|
||||
for (const name of [en.profileSaveChanges, en.profileCvVersionRestore, en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection]) {
|
||||
const control = screen.getByRole("button", { name });
|
||||
expect(control).toBeDisabled();
|
||||
control.click();
|
||||
}
|
||||
for (const button of screen.getAllByRole("button", { name: /^(Remove|Delete custom link|Bold|Italic|Underline|Link$)/ })) expect(button).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
|
||||
expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -212,22 +212,16 @@ test("the parent exposes failed import-read retry without canonical saves or his
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("overview retry cannot overwrite a dirty canonical draft after a failed source refresh", async () => {
|
||||
test("changing language in overview cannot refetch or overwrite a dirty canonical draft", async () => {
|
||||
renderCareer();
|
||||
await screen.findByText("70%");
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
|
||||
fireEvent.change(screen.getByLabelText("Full name"), { target: { value: "Unsaved canonical name" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceOverviewTab }));
|
||||
const originalGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/career/profile"
|
||||
? Promise.reject(new Error("Source refresh unavailable")) : originalGet(url));
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
const region = await screen.findByRole("region", { name: translations.nb.careerOverviewProfileStatus });
|
||||
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.nb.careerOverviewRetryDraftHint);
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
const retry = within(region).getByRole("button", { name: translations.nb.retry });
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.click(retry);
|
||||
expect(within(region).queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.careerWorkspaceProfileTab }));
|
||||
expect(screen.getByLabelText("Fullt navn")).toHaveValue("Unsaved canonical name");
|
||||
|
||||
@@ -1285,9 +1285,14 @@ export const translations = {
|
||||
profileCvLinkLabel: "Label",
|
||||
profileCvLinkUrl: "URL",
|
||||
profileCvDeleteCustomLink: "Delete custom link {index}",
|
||||
careerProfileContactSection: "Contact information",
|
||||
careerProfileRemoveJob: "Remove work experience {index}",
|
||||
careerProfileRemoveEducation: "Remove education {index}",
|
||||
careerProfileRemoveLanguage: "Remove language {index}",
|
||||
careerProfileRemoveOtherSection: "Remove custom section {index}",
|
||||
profileCvTextLabel: "Profile CV / master resume text",
|
||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||
profileCvStructuredDefaultHint: "Your career information stays front and center. Open the original import only when you need to check or clean up what was read from your file.",
|
||||
profileCvStructuredDefaultHint: "Career information is the main view. Open the original import to check what was read from your file.",
|
||||
profileCvRawPanelTitle: "Original import",
|
||||
profileCvRawPanelHelp: "Usually messy, but useful for checking what was actually read from the uploaded file.",
|
||||
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||
@@ -1300,7 +1305,7 @@ export const translations = {
|
||||
profileCvStructureParseFailed: "Couldn't read sections from your CV.",
|
||||
profileCvStructureEmpty: "No sections read yet.",
|
||||
profileCvStructuredEditor: "Career information",
|
||||
profileCvStructuredEditorHelp: "Your career facts. The CV Builder uses this information to create documents — edit it here and every CV stays up to date.",
|
||||
profileCvStructuredEditorHelp: "Changes here update inherited information in live and published CVs. CV-specific edits and frozen submission packages stay separate.",
|
||||
profileCvExtractionHistory: "Extraction history",
|
||||
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
|
||||
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
|
||||
@@ -3875,9 +3880,14 @@ export const translations = {
|
||||
profileCvLinkLabel: "Etikett",
|
||||
profileCvLinkUrl: "URL",
|
||||
profileCvDeleteCustomLink: "Slett egendefinert lenke {index}",
|
||||
careerProfileContactSection: "Kontaktinformasjon",
|
||||
careerProfileRemoveJob: "Fjern arbeidserfaring {index}",
|
||||
careerProfileRemoveEducation: "Fjern utdanning {index}",
|
||||
careerProfileRemoveLanguage: "Fjern språk {index}",
|
||||
careerProfileRemoveOtherSection: "Fjern egendefinert seksjon {index}",
|
||||
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
||||
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
||||
profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.",
|
||||
profileCvStructuredDefaultHint: "Karriereprofilen er hovedvisningen. Åpne originalimporten når du vil kontrollere det som ble lest fra filen din.",
|
||||
profileCvRawPanelTitle: "Originalt uttrekk",
|
||||
profileCvRawPanelHelp: "Som regel rotete, men nyttig når du vil se nøyaktig hva parseren hentet ut fra den opplastede filen.",
|
||||
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||
@@ -3889,8 +3899,8 @@ export const translations = {
|
||||
profileCvStructureParsed: "CV-strukturen er analysert.",
|
||||
profileCvStructureParseFailed: "Kunne ikke analysere CV-strukturen.",
|
||||
profileCvStructureEmpty: "Ingen analyserte seksjoner ennå.",
|
||||
profileCvStructuredEditor: "Strukturert CV-redigering",
|
||||
profileCvStructuredEditorHelp: "Rediger gjenbrukbare CV-data direkte slik at generatorer og matching kan jobbe fra stabile felt i stedet for bare råtekst.",
|
||||
profileCvStructuredEditor: "Karriereinformasjon",
|
||||
profileCvStructuredEditorHelp: "CV-er som bruker profilen, inkludert publiserte CV-er, får oppdatert innhold. Tilpasninger i hver CV og fryste innsendingspakker holdes separate.",
|
||||
profileCvExtractionHistory: "Ekstraksjonshistorikk",
|
||||
profileCvExtractionHistoryHelp: "Se hvilken parserkjøring som produserte den nåværende strukturerte profilen, og kjør på nytt fra det lagrede kildeartefaktet ved behov.",
|
||||
profileCvExtractionHistoryEmpty: "Ingen ekstraksjonskjøringer ennå.",
|
||||
|
||||
@@ -206,7 +206,7 @@ test('Career Profile section headings describe the visible hierarchy without cha
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'));
|
||||
expect(screen.getByRole('heading', { name: 'Career profile', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Career information', level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Career information', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
||||
let previous = 1;
|
||||
for (const heading of screen.getAllByRole('heading')) {
|
||||
@@ -504,7 +504,7 @@ test('profile page shows durable CV operation state and retries a failed run', a
|
||||
test('profile page keeps raw extraction collapsed until expanded', async () => {
|
||||
renderPage('import');
|
||||
|
||||
expect(await screen.findByText(/your career information stays front and center/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Career information is the main view/i)).toBeInTheDocument();
|
||||
|
||||
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
|
||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import type { UserOperation } from "../types";
|
||||
import {
|
||||
emptyStructuredCv,
|
||||
@@ -147,11 +148,16 @@ export default function CareerProfilePage() {
|
||||
const careerOnly = true;
|
||||
const { toast } = useToast();
|
||||
const { language, t } = useI18n();
|
||||
const translationRef = useRef(t);
|
||||
useEffect(() => { translationRef.current = t; }, [t]);
|
||||
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const profileMutationPending = useRef(false);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
const [improvingCv, setImprovingCv] = useState(false);
|
||||
const [rebuildingCv, setRebuildingCv] = useState(false);
|
||||
@@ -192,6 +198,8 @@ export default function CareerProfilePage() {
|
||||
}, []);
|
||||
|
||||
const restoreVersion = useCallback(async (version: number) => {
|
||||
if (profileMutationPending.current || me?.provider !== "local" || loading) return;
|
||||
profileMutationPending.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
|
||||
@@ -203,9 +211,10 @@ export default function CareerProfilePage() {
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error");
|
||||
} finally {
|
||||
profileMutationPending.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadVersions, t, toast]);
|
||||
}, [loadVersions, loading, me?.provider, t, toast]);
|
||||
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||
const [runsLoading, setRunsLoading] = useState(true);
|
||||
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
|
||||
@@ -269,13 +278,13 @@ export default function CareerProfilePage() {
|
||||
setProfileDirty(false);
|
||||
setHeadline(profile.contact.headline ?? "");
|
||||
setLoadError(null);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
setMe(null);
|
||||
setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed")));
|
||||
setLoadError(getApiErrorMessage(error, ""));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
@@ -328,6 +337,8 @@ export default function CareerProfilePage() {
|
||||
|
||||
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
||||
const isLocal = me?.provider === "local";
|
||||
const editorDisabled = !isLocal || loading || saving;
|
||||
const loadErrorMessage = loadError === null ? null : loadError || t("profileLoadFailed");
|
||||
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
||||
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
|
||||
|
||||
@@ -345,8 +356,8 @@ export default function CareerProfilePage() {
|
||||
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
|
||||
const extractionProgress = activeCvRun?.operation?.progressPercent;
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0 }}>
|
||||
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}>
|
||||
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}>
|
||||
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
|
||||
<Select
|
||||
@@ -378,8 +389,8 @@ export default function CareerProfilePage() {
|
||||
<CareerWorkspaceOverview
|
||||
completeness={completeness}
|
||||
runs={extractionRuns}
|
||||
loading={loading}
|
||||
loadError={loadError}
|
||||
loading={loading || saving}
|
||||
loadError={loadErrorMessage}
|
||||
runsLoading={runsLoading}
|
||||
runsLoadError={runsLoadError}
|
||||
onRetryRuns={() => void loadExtractionRuns()}
|
||||
@@ -391,7 +402,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
|
||||
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
|
||||
<input
|
||||
ref={cvInputRef}
|
||||
type="file"
|
||||
@@ -406,7 +417,7 @@ export default function CareerProfilePage() {
|
||||
{workspaceSection === "profile" ? <ProfileCompleteness
|
||||
completeness={completeness}
|
||||
versions={versions}
|
||||
loading={loading}
|
||||
loading={editorDisabled}
|
||||
onRestore={(version) => void restoreVersion(version)}
|
||||
showSummary={false}
|
||||
/> : null}
|
||||
@@ -438,10 +449,10 @@ export default function CareerProfilePage() {
|
||||
}}
|
||||
/> : null}
|
||||
|
||||
{loadError ? (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
|
||||
{loadErrorMessage ? (
|
||||
<Alert severity="error" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={profileDirty || loading || saving} onClick={() => { if (!profileDirty && !saving) void loadProfile(); }}>{t("retry")}</Button>}>
|
||||
{t("profileLoadFailed")}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
||||
{loadError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography> : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -452,9 +463,9 @@ export default function CareerProfilePage() {
|
||||
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileOutlinedIcon />}
|
||||
disabled={!canUseAi || !isLocal || uploadingCv || Boolean(activeCvRun)}
|
||||
disabled={!canUseAi || editorDisabled || uploadingCv || Boolean(activeCvRun)}
|
||||
onClick={() => cvInputRef.current?.click()}
|
||||
>
|
||||
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
||||
@@ -476,7 +487,7 @@ export default function CareerProfilePage() {
|
||||
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
|
||||
{t("careerProfileImportReady")}
|
||||
</Alert> : null}
|
||||
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
|
||||
{!canUseAi ? <Box sx={{ mt: 1.5, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, overflowWrap: "anywhere" } }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
|
||||
</Paper> : null}
|
||||
|
||||
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
@@ -643,7 +654,7 @@ export default function CareerProfilePage() {
|
||||
helperText={t("profileCvTextHelp")}
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
disabled={editorDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
|
||||
@@ -783,7 +794,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
|
||||
disabled={editorDisabled || !profileCvText.trim() || parsingCvSections}
|
||||
onClick={async () => {
|
||||
setParsingCvSections(true);
|
||||
try {
|
||||
@@ -821,33 +832,32 @@ export default function CareerProfilePage() {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
|
||||
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface>
|
||||
|
||||
<PersonalInformationSection
|
||||
value={structuredCv.contact}
|
||||
onChange={(next) => editStructuredCv((prev) => ({ ...prev, contact: next }))}
|
||||
getMetadata={metaFor}
|
||||
disabled={editorDisabled}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
|
||||
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
|
||||
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
|
||||
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
||||
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
||||
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
||||
</Box>
|
||||
|
||||
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} />
|
||||
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} disabled={editorDisabled} />
|
||||
|
||||
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
|
||||
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
||||
|
||||
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} />
|
||||
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} />
|
||||
|
||||
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
|
||||
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} />
|
||||
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} disabled={editorDisabled} />
|
||||
</PageSection>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
@@ -860,12 +870,20 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ width: "100%" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
||||
{saveError !== null ? <Alert severity="error" sx={{ width: "100%" }}>
|
||||
{t("profileUpdateFailed")}
|
||||
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || loading}
|
||||
disabled={editorDisabled}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
if (profileMutationPending.current || editorDisabled) return;
|
||||
profileMutationPending.current = true;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
// Save the master profile through the relational source of truth. The endpoint
|
||||
// persists the structured children + version and keeps the legacy blob projection
|
||||
@@ -875,15 +893,17 @@ export default function CareerProfilePage() {
|
||||
setCompleteness(saved.data?.completeness ?? null);
|
||||
setProfileDirty(false);
|
||||
toast(t("profileUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
||||
toast(String(msg), "error");
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "");
|
||||
setSaveError(message);
|
||||
toast(message || translationRef.current("profileUpdateFailed"), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
profileMutationPending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("profileSaveChanges")}
|
||||
{saving ? t("saving") : t("profileSaveChanges")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
|
||||
import RichTextField from "../../components/RichTextField";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import { PageSection } from "../../layout/PageLayout";
|
||||
import {
|
||||
joinLines,
|
||||
splitLines,
|
||||
@@ -62,190 +63,182 @@ export function PersonalInformationSection({
|
||||
value,
|
||||
onChange,
|
||||
getMetadata,
|
||||
disabled,
|
||||
}: {
|
||||
value: StructuredCvContact;
|
||||
onChange: (next: StructuredCvContact) => void;
|
||||
getMetadata: MetadataLookup;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const set = (patch: Partial<StructuredCvContact>) => onChange({ ...value, ...patch });
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<PageSection title={t("careerProfileContactSection")} level="h3">
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>
|
||||
<Box>
|
||||
<TextField label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
|
||||
<FieldReviewNote metadata={getMetadata("contact.fullName")} />
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField label={t("profileCvContactHeadline")} value={value.headline ?? ""} onChange={(e) => set({ headline: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvContactHeadline")} value={value.headline ?? ""} onChange={(e) => set({ headline: e.target.value || undefined })} fullWidth />
|
||||
<FieldReviewNote metadata={getMetadata("contact.headline")} />
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField label={t("profileCvContactEmail")} value={value.email ?? ""} onChange={(e) => set({ email: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvContactEmail")} value={value.email ?? ""} onChange={(e) => set({ email: e.target.value || undefined })} fullWidth />
|
||||
<FieldReviewNote metadata={getMetadata("contact.email")} />
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField label={t("profileCvContactPhone")} value={value.phone ?? ""} onChange={(e) => set({ phone: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvContactPhone")} value={value.phone ?? ""} onChange={(e) => set({ phone: e.target.value || undefined })} fullWidth />
|
||||
<FieldReviewNote metadata={getMetadata("contact.phone")} />
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField label={t("profileCvContactLocation")} value={value.location ?? ""} onChange={(e) => set({ location: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvContactLocation")} value={value.location ?? ""} onChange={(e) => set({ location: e.target.value || undefined })} fullWidth />
|
||||
<FieldReviewNote metadata={getMetadata("contact.location")} />
|
||||
</Box>
|
||||
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<TextField disabled={disabled} label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<TextField disabled={disabled} label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
|
||||
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button disabled={disabled} size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
|
||||
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField disabled={disabled} size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: { xs: "0 1 auto", sm: "0 1 180px" } }} /><TextField disabled={disabled} size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton disabled={disabled} aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared free-text list editor (one item per line) for Summary / Skills / Interests.
|
||||
function LinesField({ label, value, onChange, metadata, minRows }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number }) {
|
||||
function LinesField({ label, value, onChange, metadata, minRows, disabled }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Box>
|
||||
<TextField label={label} value={joinLines(value)} onChange={(e) => onChange(splitLines(e.target.value))} helperText={t("profileCvStructuredListHelp")} multiline minRows={minRows} fullWidth />
|
||||
<TextField disabled={disabled} label={label} value={joinLines(value)} onChange={(e) => onChange(splitLines(e.target.value))} helperText={t("profileCvStructuredListHelp")} multiline minRows={minRows} fullWidth />
|
||||
<FieldReviewNote metadata={metadata} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfessionalSummarySection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
|
||||
export function ProfessionalSummarySection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Box>
|
||||
<RichTextField label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
|
||||
<RichTextField disabled={disabled} label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
|
||||
<FieldReviewNote metadata={getMetadata("summary")} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
|
||||
export function SkillsSection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} />;
|
||||
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function InterestsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
|
||||
export function InterestsSection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} />;
|
||||
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function LongTailSections({ values, onChange }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void }) {
|
||||
export function LongTailSections({ values, onChange, disabled }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const labels = { awards: t("profileCvStructuredAwards"), publications: t("profileCvStructuredPublications"), organisations: t("profileCvStructuredOrganisations"), references: t("profileCvStructuredReferences") };
|
||||
return <Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <LinesField key={key} label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} />)}</Box>;
|
||||
return <Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <LinesField key={key} label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} disabled={disabled} />)}</Box>;
|
||||
}
|
||||
|
||||
export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) {
|
||||
export function LanguagesSection({ value, onChange, getMetadata, disabled }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const update = (index: number, patch: Partial<StructuredCvLanguage>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>
|
||||
</Box>
|
||||
<PageSection title={t("profileCvStructuredLanguages")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>}>
|
||||
|
||||
<FieldReviewNote metadata={getMetadata("languages")} />
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
{value.map((language, index) => (
|
||||
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr auto" }, gap: 1 }}>
|
||||
<TextField label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
|
||||
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||
<Box key={`language-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto" }, gap: 1 }}>
|
||||
<TextField disabled={disabled} label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
|
||||
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveLanguage", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkExperienceSection({ value, onChange }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void }) {
|
||||
export function WorkExperienceSection({ value, onChange, disabled }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const update = (index: number, patch: Partial<StructuredCvJob>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>
|
||||
</Box>
|
||||
<PageSection title={t("profileCvStructuredJobs")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>}>
|
||||
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
{value.map((job, index) => (
|
||||
<Box key={`job-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
|
||||
<TextField label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
|
||||
<Box key={`job-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1 }}>
|
||||
<TextField disabled={disabled} label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 1 }}>
|
||||
<TextField disabled={disabled} label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
|
||||
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
|
||||
<TextField disabled={disabled} label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveJob", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function EducationSection({ value, onChange }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void }) {
|
||||
export function EducationSection({ value, onChange, disabled }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const update = (index: number, patch: Partial<StructuredCvEducation>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>
|
||||
</Box>
|
||||
<PageSection title={t("profileCvStructuredEducation")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>}>
|
||||
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
{value.map((education, index) => (
|
||||
<Box key={`education-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
|
||||
<TextField label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
|
||||
<Box key={`education-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1 }}>
|
||||
<TextField disabled={disabled} label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 1 }}>
|
||||
<TextField disabled={disabled} label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
|
||||
<TextField disabled={disabled} label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
|
||||
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveEducation", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function OtherSectionsSection({ value, onChange }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void }) {
|
||||
export function OtherSectionsSection({ value, onChange, disabled }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void; disabled: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const update = (index: number, patch: Partial<StructuredCvOtherSection>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>
|
||||
</Box>
|
||||
<PageSection title={t("profileCvStructuredOtherSections")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>}>
|
||||
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
{value.map((section, index) => (
|
||||
<Box key={`other-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
|
||||
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
|
||||
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
|
||||
<Box key={`other-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto" }, gap: 1 }}>
|
||||
<TextField disabled={disabled} label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
|
||||
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveOtherSection", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user