feat(ui): migrate CV library actions and recovery
This commit is contained in:
@@ -35,3 +35,6 @@ Execute the exact approved P7-00…P7-12 plus optional P7-06F from [the handoff]
|
||||
Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
|
||||
Exact direct receipt is retained at `.artifacts/phase7/p7-03/import-history/final/push-receipt.json`; the portable results include its raw hash and observation. No runtime bytes were replaced by this metadata successor.
|
||||
|
||||
| P7-03 observed push receipt / P7-04 entry | `5680c0031f377b0caaae79c0cec900f58e409886` | Records the actual preceding 5d6e3235 clean normal push, closes implemented P7-03 conditionally and establishes exact library/public entry baselines. Entry static PASS (four commands0,11docs/400 local links0errors/57groups/32routes); normal push directly verified2026-10-12 17:35:00.882169UTC: localHEAD=origin=live5680c00, clean worktree, local/origin/live main27657f3 unchanged. No product/runtime/count change |
|
||||
| P7-04 CV library | Pending normal commit/push after final staged gate | [Library report](p7-04/README.md#cv-library-implemented-result), [finite results](p7-04/evidence/library-results.json): affected38/4/native8/editor4/backend72/types/lint/build PASS;19 actually reviewed PNGs;19 all-enabled scans0/64 plus12 retained editor scans0/122 with manual conditions. Current10 sources/75-file export verified against exact frozen final; +12/0 gives660/93 and +8 gives241 derived only. Public entry remains pending; no main/renderer/backend/AI activation |
|
||||
|
||||
@@ -126,3 +126,7 @@ Production remains NO-GO until RD01–05/RD27 plus effective schema/configuratio
|
||||
### Observed import/history push receipt
|
||||
|
||||
Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
|
||||
### P7-04 library finite disposition
|
||||
|
||||
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
|
||||
|
||||
@@ -144,3 +144,7 @@ Complete affected migration acceptance requires applicable evidence or an explic
|
||||
### Observed import/history push receipt
|
||||
|
||||
Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
|
||||
### P7-04 library finite disposition
|
||||
|
||||
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
|
||||
|
||||
@@ -52,3 +52,7 @@ Verify EN/Bokmål, light/dark, appropriate density, 320/360–390/768/1024/1280/
|
||||
### Observed import/history push receipt
|
||||
|
||||
Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
|
||||
### P7-04 library finite disposition
|
||||
|
||||
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
|
||||
|
||||
@@ -10,3 +10,17 @@
|
||||
| Public entry | Previous slug HTML/error remain during the next request; hard-coded English/theme styles and no semantic page heading. Retain late-completion guard, white print canvas and existing sandbox | Slug isolation/error reset/late request/Retry; missing/revoked links; anonymous public HTML/PDF versus private numeric IDs; narrow responsive chrome, unchanged geometry and prescribed fidelity when output/preview is touched |
|
||||
|
||||
PRES-C05/C12/E09 and affected revision/source/export contracts remain binding; all57 whole-group statuses stay56 PARTIAL/C11 DEFERRED at entry. Rename retains latest settings and expectedVersion; publication/revocation stays with the editor/server owner. No second history/renderer/editor, new lifecycle/schema, automatic model/provider action or Phase8 work. Human screen-reader/device/zoom/production font/photo/print evidence remains pending. Production remains NO-GO; main is unchanged.
|
||||
|
||||
### CV library implemented result
|
||||
|
||||
**Library implemented scope CONDITIONAL GO; normal push pending. Public-entry boundary remains pending.** [Final staged hygiene](evidence/library-static-checks.json) PASS at2026-10-12 18:03:12UTC: four commands0,11docs/406 local links0errors,57groups/32routes retained. [Portable finite evidence](evidence/library-results.json) freezes current source and the 75-file `_fYf-IvM_ULd3ZPdUW-8h` export separately from earlier failures;289 exact file references and current10 sources/export match. Fresh affected **38/4**, new ordinary native **8**, unchanged-binary backend **72**, and existing editor/gallery-consumer **4** pass; app/browser types, lint and production build pass. No test is skipped or expected to fail. The existing editor journeys retain actual private PDF200 and gallery/template/settings behavior; the two new 503/504 cases qualify controlled failure presentation only.
|
||||
|
||||
Shared PageHeader H2 sits below the existing shell H1; cards use H3, density tokens and quiet surfaces. Shared resource feedback adds visible loading, localized error and explicit Retry without locale refetch. Pending snapshots prevent repeat mutations and protect draft/template choice; existing destructive confirmations and latest-settings/expectedVersion rename remain. Durable errors survive Cancel/reopen and locale changes; pending completion returns focus only while it remains in the managed region. The shared gallery's optional disabled prop defaults false for other consumers; unavailable Pro templates remain visible and guarded.
|
||||
|
||||
Meaningful RED coverage and peer review reproduced draft/error loss, stale language and late focus theft before correction. Native candidate6/2 also found a duplicate shell/local H1, helper contrast4.48:1 and a pending scroller without a keyboard target. The final local H2, readable disabled helper and named focusable pending DialogContent correct those demonstrated barriers. Raw failed outcomes/source qualifications remain; an initial Node collection error ran0 cases and invalid MUI-exit fixtures are identified rather than called product defects.
|
||||
|
||||
All **19 final PNGs were displayed and reviewed** across a finite EN/Bokmål/light/dark/320/360/390/768/1024/1280-landscape/1440/1920 sample, not the full Cartesian matrix. Long names wrap and root overflow is0. Full-page capture fixed/scroll/focus context, including off-viewport modal backdrop, is qualified. White CV thumbnails remain white in dark mode. Server-authored template names/categories remain English metadata in Bokmål samples; P7-11 must review that residual without changing stored theme IDs or print content.
|
||||
|
||||
New library **19 all-enabled scans0 violations/64 incomplete occurrences** retain19 invisible-badge short-text,17 modal/menu hidden-focus and28 background overlap. Existing editor **12 retained-scope scans0/122** retain24 disabled Undo/Redo wrapper aria labels,12 invisible-badge short-text,78 overlap and8 obscured iframe-heading occurrences. Full vectors remain; neither scope establishes human screen-reader/modal/contrast acceptance. M09/M11 and all M01–M12 device/zoom/output/provider gates remain. RD21 canonical concurrency/partial-write risk and historical frozen/orphan/output debt are unchanged.
|
||||
|
||||
Twelve new cases in the existing suite give **660/93 frontend derived only**; eight new ordinary browser cases give **241 derived only**. Latest whole frontend573/89, backend1,033 and sidecar49 are inherited; backend1,034 is derived. P7-12 must run fresh integrated regression. All57 statuses remain56 PARTIAL/C11 DEFERRED,32 routes retained; P5-13 stays withheld. No backend/schema/renderer/PDF geometry or Phase8 work. Production NO-GO/main unchanged.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-04",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-12T18:03:13.189229+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-12T18:03:10.190212+00:00",
|
||||
"finishedUtc": "2026-10-12T18:03:12.572572+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T180310Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T18:03:12.572572+00:00",
|
||||
"finishedUtc": "2026-10-12T18:03:12.657510+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T180310Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T18:03:12.658011+00:00",
|
||||
"finishedUtc": "2026-10-12T18:03:12.747119+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T180310Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-12T18:03:12.747624+00:00",
|
||||
"finishedUtc": "2026-10-12T18:03:12.872764+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T180310Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 11,
|
||||
"relativeLinks": 406,
|
||||
"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."
|
||||
]
|
||||
}
|
||||
@@ -86,7 +86,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-01 public authentication and separate account/security presentation (implemented scope CONDITIONAL GO; account pushedad7b419/direct receipt19:16:15UTC): public sub-slice CONDITIONAL GO, pushed `8ad720d`, direct remote=HEAD verified2026-10-11 18:50:24UTC, main unchanged; affected61/native22/isolated SMTP2/helper6/auth subset87 pass. [Checkpoint record](../redesign/phase-7/p7-01/README.md) retains manual/provider/production gates. Fresh account full573/89, native6/public22/auth-boundaries10 and types/lint/build pass; all eight final post-copy PNGs actually inspected and source/context evidence frozen. Account85 and public31 current axe incompletes remain qualified; final staged finite hygiene passed; normal account pushad7b419 directly verified2026-10-11 19:16:15UTC with localHEAD=origin=live equality and clean worktree at observation, main unchanged.
|
||||
- [x] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push8b4d5c2 directly verified05:51:04UTC, localHEAD=origin=live/clean worktree/main unchanged. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
|
||||
- [x] P7-03 Career structured editor and import/history: Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
- [ ] P7-04 CV library and public entry.
|
||||
- [ ] P7-04 CV library and public entry: Library finite implementation CONDITIONAL GO (38/4 unit,8 native,4 editor,72 backend,19 actual PNG reviews; types/lint/build pass); staged hygiene/normal push and separate public entry pending. Manual/M09/M11/production qualifications retained.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders.
|
||||
- [ ] P7-06 remaining Application forms and global correspondence.
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type Response, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { CvTheme, CvVariant, CvVariantSummary } from '../src/cvBuilder';
|
||||
import { emptyStructuredCv } 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 };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
|
||||
function settings(themeId: string) {
|
||||
return { themeId, showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] };
|
||||
}
|
||||
|
||||
async function json<T>(response: APIResponse | Response, 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 } };
|
||||
}
|
||||
|
||||
async function createOwner(admin: APIRequestContext, owner: Owner) {
|
||||
const email = `p7-cv-library-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
return email;
|
||||
}
|
||||
|
||||
const test = base.extend<{ owner: Owner }>({
|
||||
owner: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
|
||||
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const owner = await login(page.request, await createOwner(admin, administrator));
|
||||
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 seedSource(page: Page, owner: Owner) {
|
||||
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
|
||||
fullName: 'Åse Nordlys — recorded owner-reviewed professional identity', headline: 'Recorded professional context', email: 'recorded-contact@example.test' },
|
||||
jobs: [{ id: 'library-source-job', title: 'Recorded engineer', company: 'Nordlys AS', bullets: ['Recorded source evidence.'], skills: ['C#'] }],
|
||||
summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'] };
|
||||
await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
|
||||
data: { profile, cvText: 'Exact owner-reviewed source — æøå\nRecorded work.\n' } }));
|
||||
return readSource(page.request);
|
||||
}
|
||||
|
||||
async function readSource(api: APIRequestContext) {
|
||||
return { canonical: await json<unknown>(await api.get(`${apiUrl}/career/profile`)),
|
||||
history: await json<unknown[]>(await api.get(`${apiUrl}/career/profile/versions`)) };
|
||||
}
|
||||
|
||||
async function createVariant(api: APIRequestContext, owner: Owner, name: string, jobApplicationId?: number) {
|
||||
return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
|
||||
data: { name, jobApplicationId, settings: { ...settings('minimal'), language: 'nb-NO',
|
||||
overrides: { 'library-source-job': { title: 'CV-specific reviewed title', bullets: ['CV-specific reviewed evidence.'] } } } } }));
|
||||
}
|
||||
|
||||
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
|
||||
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(({ language, theme }) => {
|
||||
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
|
||||
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/career/builder?source=phase7-library#preserved');
|
||||
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
|
||||
if (stored.language !== sample.language || stored.theme !== sample.theme) {
|
||||
await page.evaluate(({ language, theme }) => {
|
||||
localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.reload();
|
||||
}
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 2, name: translations[sample.language].cvDashboardLibraryTitle, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
function card(page: Page, name: string, language: Sample['language'] = 'en') {
|
||||
return page.getByRole('link', { name: translations[language].cvDashboardOpenNamed.replace('{name}', name), exact: true });
|
||||
}
|
||||
|
||||
function actionButton(page: Page, name: string, language: Sample['language'] = 'en') {
|
||||
return page.getByRole('button', { name: translations[language].cvDashboardActionsFor.replace('{name}', name), exact: true });
|
||||
}
|
||||
|
||||
async function openAction(page: Page, name: string, action: string, language: Sample['language'] = 'en') {
|
||||
await actionButton(page, name, language).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole('menuitem', { name: action, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
async function openCreate(page: Page, language: Sample['language'] = 'en') {
|
||||
const t = translations[language];
|
||||
await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
|
||||
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
overflowElements: [...document.querySelectorAll('body *')]
|
||||
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
|
||||
.map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; })
|
||||
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20),
|
||||
}));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-cv-library-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(),
|
||||
axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete,
|
||||
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Automated Chromium only; incomplete axe findings require triage and do not establish human screen-reader acceptance.',
|
||||
'No physical touch/software keyboard, actual browser zoom, production font/photo, print/PDF fidelity, 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(axe.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-04 PRES-C05 E08] failed list has an explicit keyboard retry with held progress, stable focus and actual recovery', async ({ page, owner }, info) => {
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const existing = await createVariant(page.request, owner, 'Recorded recovery CV');
|
||||
let reads = 0;
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route('**/api/cv/variants', async route => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
reads++;
|
||||
if (reads === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled library read unavailable' }) });
|
||||
if (reads === 2) await held;
|
||||
return route.continue();
|
||||
});
|
||||
await enter(page, owner);
|
||||
await expect(page.getByText('Controlled library read unavailable', { exact: true })).toBeVisible();
|
||||
const retry = page.getByRole('button', { name: translations.en.retry, exact: true });
|
||||
const content = page.getByRole('region', { name: translations.en.cvDashboardTitle, exact: true });
|
||||
try {
|
||||
await retry.focus(); await page.keyboard.press('Enter');
|
||||
await expect.poll(() => reads).toBe(2);
|
||||
await expect(content).toBeFocused();
|
||||
await expect(page.getByRole('progressbar', { name: translations.en.loading, exact: true })).toBeVisible();
|
||||
await expect(content.getByRole('status')).toHaveText(translations.en.loading);
|
||||
await expect(content.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
await page.keyboard.press('Enter');
|
||||
expect(reads).toBe(2);
|
||||
} finally { release(); }
|
||||
await expect(card(page, existing.name)).toBeVisible();
|
||||
await expect(page.getByText('Controlled library read unavailable', { exact: true })).toHaveCount(0);
|
||||
await expect(content).toBeFocused();
|
||||
expect(reads).toBe(2);
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
await capture(page, info, desktop, 'list-recovered');
|
||||
await info.attach('library-list-recovery', { body: JSON.stringify({ reads, qualification: 'First read is a controlled500; explicit retry is held before continuing the actual HTTP GET. No successful CV response is fabricated.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C05 C08 E08] locale-retained create draft locks during held failure and retries one identical snapshot', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const themes = await json<CvTheme[]>(await page.request.get(`${apiUrl}/cv/themes`));
|
||||
const selected = themes.find(theme => theme.id === 'minimal' && theme.available)!;
|
||||
expect(selected).toBeDefined();
|
||||
let listReads = 0;
|
||||
let themeReads = 0;
|
||||
const payloads: unknown[] = [];
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route('**/api/cv/variants', async route => {
|
||||
if (route.request().method() === 'GET') { listReads++; return route.continue(); }
|
||||
if (route.request().method() !== 'POST') 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 create unavailable' }) });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.route('**/api/cv/themes', route => { themeReads++; return route.continue(); });
|
||||
await enter(page, owner, { ...desktop, width: 360, height: 844 });
|
||||
await expect(page.getByText(translations.en.cvDashboardEmptyTitle, { exact: true })).toBeVisible();
|
||||
const name = 'Backendutvikler — Åse sin gjennomgåtte CV med en lang, beskrivende tittel';
|
||||
let dialog = await openCreate(page);
|
||||
await dialog.getByLabel(translations.en.cvEditorName, { exact: false }).fill(name);
|
||||
await dialog.getByRole('button', { name: selected.name, exact: true }).focus(); await page.keyboard.press('Enter');
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await dialog.getByRole('button', { name: translations.en.cancel, exact: true }).click();
|
||||
const readsBeforeLocale = { listReads, themeReads };
|
||||
const saved = 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 saved).status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
dialog = await openCreate(page, 'nb');
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name);
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect({ listReads, themeReads }).toEqual(readsBeforeLocale);
|
||||
try {
|
||||
await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).focus(); await page.keyboard.press('Enter');
|
||||
await expect.poll(() => payloads.length).toBe(1);
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveAttribute('required', '');
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cancel, exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cvEditorAtsTemplates, exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
await page.keyboard.press('Enter'); await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeVisible(); expect(payloads).toHaveLength(1);
|
||||
const content = dialog.getByRole('region', { name: translations.nb.cvDashboardCreateTitle, exact: true });
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(content).toBeFocused();
|
||||
const scrollBefore = await content.evaluate(element => ({ top: element.scrollTop, height: element.clientHeight, scrollHeight: element.scrollHeight }));
|
||||
if (scrollBefore.scrollHeight > scrollBefore.height + 1) {
|
||||
await page.keyboard.press(scrollBefore.top > 0 ? 'PageUp' : 'PageDown');
|
||||
if (scrollBefore.top > 0) await expect.poll(() => content.evaluate(element => element.scrollTop)).toBeLessThan(scrollBefore.top);
|
||||
else await expect.poll(() => content.evaluate(element => element.scrollTop)).toBeGreaterThan(scrollBefore.top);
|
||||
}
|
||||
await info.attach('library-held-create-keyboard-scroll', { body: JSON.stringify({ scrollBefore,
|
||||
scrollAfter: await content.evaluate(element => element.scrollTop),
|
||||
qualification: 'Actual Tab reaches the named pending-dialog content; PageUp/PageDown is exercised when this viewport has vertical content overflow. Browser keyboard evidence does not establish physical touch/software-keyboard or human screen-reader acceptance.' }), contentType: 'application/json' });
|
||||
await capture(page, info, { language: 'nb', theme: 'light', width: 360, height: 844 }, 'held-create');
|
||||
} finally { release(); }
|
||||
await expect(dialog.getByText('Controlled create unavailable', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name);
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const createdResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants` && response.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).click();
|
||||
const created = await json<CvVariant>(await createdResponse);
|
||||
await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}(?:[?#]|$)`));
|
||||
expect(payloads).toHaveLength(2); expect(payloads[1]).toEqual(payloads[0]);
|
||||
expect(created).toMatchObject({ name, isPublic: false, version: 1, settings: { themeId: 'minimal' } });
|
||||
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${created.id}`))).toEqual(created);
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
await info.attach('library-create-recovery', { body: JSON.stringify({ readsBeforeLocale, listReads, themeReads, payloads, created,
|
||||
qualification: 'Locale is changed through the real header after cancelling the draft dialog. First create is a controlled500; only explicit retry reaches actual HTTP creation. No model/provider or source write.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C05 C07 C10 W07] keyboard rename duplicate and delete persist without changing source, settings or frozen packages', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded library fixture AS' } }), 201);
|
||||
const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
|
||||
data: { companyId: company.id, jobTitle: 'Recorded library association', status: 'Saved' } }), 201);
|
||||
const original = await createVariant(page.request, owner, 'Recorded original CV', job.id);
|
||||
expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: original.id } })).status()).toBe(200);
|
||||
const frozen = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers }));
|
||||
const frozenUrl = `${apiUrl}/jobapplications/${job.id}/submitted-packages/${frozen.id}`;
|
||||
const frozenBefore = await json<unknown>(await page.request.get(frozenUrl));
|
||||
await enter(page, owner);
|
||||
const renamedName = 'Recorded renamed CV — explicit owner review';
|
||||
await openAction(page, original.name, translations.en.cvDashboardRename);
|
||||
const prompt = page.getByRole('dialog');
|
||||
await expect(prompt.getByRole('textbox')).toBeFocused();
|
||||
await prompt.getByRole('textbox').fill(renamedName);
|
||||
const renamedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT');
|
||||
await prompt.getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click();
|
||||
const renameResponse = await renamedResponse;
|
||||
const renamed = await json<CvVariant>(renameResponse);
|
||||
expect(renameResponse.request().postDataJSON()).toMatchObject({ name: renamedName, source: 'manual', expectedVersion: original.version, settings: original.settings });
|
||||
expect(renamed.settings).toEqual(original.settings); expect(renamed.version).toBe(original.version + 1);
|
||||
await expect(card(page, renamedName)).toBeVisible();
|
||||
await expect(actionButton(page, renamedName)).toBeFocused();
|
||||
let duplicateWrites = 0;
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`**/api/cv/variants/${original.id}/duplicate`, async route => { duplicateWrites++; await held; return route.continue(); });
|
||||
const duplicatedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}/duplicate` && response.request().method() === 'POST');
|
||||
try {
|
||||
await openAction(page, renamedName, translations.en.cvEditorDuplicate);
|
||||
await expect.poll(() => duplicateWrites).toBe(1);
|
||||
await expect(actionButton(page, renamedName)).toBeDisabled();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(duplicateWrites).toBe(1);
|
||||
} finally { release(); }
|
||||
const copy = await json<CvVariant>(await duplicatedResponse);
|
||||
expect(copy).toMatchObject({ name: `${renamedName} (copy)`, version: 1, isPublic: false, jobApplicationId: job.id });
|
||||
expect(copy.id).not.toBe(original.id); expect(copy.publicSlug).not.toBe(original.publicSlug); expect(copy.settings).toEqual(renamed.settings);
|
||||
await expect(card(page, copy.name)).toBeVisible();
|
||||
await openAction(page, copy.name, translations.en.cvDashboardDelete);
|
||||
const confirmation = page.getByRole('dialog');
|
||||
await expect(confirmation.getByRole('button', { name: translations.en.cancel, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
await expect(actionButton(page, copy.name)).toBeFocused();
|
||||
expect((await page.request.get(`${apiUrl}/cv/variants/${copy.id}`)).status()).toBe(200);
|
||||
await openAction(page, copy.name, translations.en.cvDashboardDelete);
|
||||
const deletedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${copy.id}` && response.request().method() === 'DELETE');
|
||||
await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardDeleteCv, exact: true }).focus(); await page.keyboard.press('Enter');
|
||||
expect((await deletedResponse).status()).toBe(204);
|
||||
await expect(card(page, copy.name)).toHaveCount(0);
|
||||
await expect(page.getByRole('region', { name: translations.en.cvDashboardTitle, exact: true })).toBeFocused();
|
||||
expect((await page.request.get(`${apiUrl}/cv/variants/${copy.id}`)).status()).toBe(404);
|
||||
expect(await json<unknown[]>(await page.request.get(`${apiUrl}/cv/variants/${copy.id}/versions`))).toEqual([]);
|
||||
await page.reload();
|
||||
await expect(card(page, renamedName)).toBeVisible(); await expect(card(page, copy.name)).toHaveCount(0);
|
||||
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(renamed);
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
expect(await json<unknown>(await page.request.get(frozenUrl))).toEqual(frozenBefore);
|
||||
await capture(page, info, desktop, 'crud-reloaded');
|
||||
await info.attach('library-crud-readback', { body: JSON.stringify({ original, renamed, copy, duplicateWrites, frozenBefore,
|
||||
sourceUnchanged: true, frozenUnchanged: true, qualification: 'Actual HTTP rename/duplicate/delete and frozen DTO readback; held duplicate continues the real request. Frozen DTO equality is not archived PDF-byte or restore qualification.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C05 C10] concurrent rename receives ordinary409 and requires owner review before a new save', async ({ page, owner }, info) => {
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const original = await createVariant(page.request, owner, 'Recorded conflict CV');
|
||||
await enter(page, owner);
|
||||
const writes: unknown[] = [];
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`**/api/cv/variants/${original.id}`, async route => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
writes.push(route.request().postDataJSON()); if (writes.length === 1) await held;
|
||||
return route.continue();
|
||||
});
|
||||
await openAction(page, original.name, translations.en.cvDashboardRename);
|
||||
await page.getByRole('dialog').getByRole('textbox').fill('Unsaved rename that must not win');
|
||||
const conflictedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT');
|
||||
let intervening!: CvVariant;
|
||||
try {
|
||||
await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
intervening = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}`, { headers: owner.headers,
|
||||
data: { name: 'Recorded intervening owner change', settings: original.settings, source: 'manual', expectedVersion: original.version } }));
|
||||
} finally { release(); }
|
||||
const conflict = await conflictedResponse;
|
||||
expect(conflict.status()).toBe(409); expect(await conflict.json()).toMatchObject({ code: 'cv_revision_conflict' });
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'changed elsewhere' })).toBeVisible();
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(intervening);
|
||||
await page.reload(); await expect(card(page, intervening.name)).toBeVisible();
|
||||
await openAction(page, intervening.name, translations.en.cvDashboardRename);
|
||||
await expect(page.getByRole('dialog').getByRole('textbox')).toHaveValue(intervening.name);
|
||||
await page.getByRole('dialog').getByRole('textbox').fill('Recorded rename after explicit reload and review');
|
||||
const reviewedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT');
|
||||
await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click();
|
||||
const reviewed = await json<CvVariant>(await reviewedResponse);
|
||||
expect(writes).toHaveLength(2); expect(writes[1]).toMatchObject({ expectedVersion: intervening.version, settings: intervening.settings, source: 'manual' });
|
||||
expect(reviewed.version).toBe(intervening.version + 1); expect(reviewed.settings).toEqual(intervening.settings);
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
await info.attach('library-ordinary-rename-conflict', { body: JSON.stringify({ original, intervening, writes, reviewed,
|
||||
qualification: 'Both saves reach the actual server; browser PUT is held while an owner-authorized API save advances its revision. No expected-failure/skip or automatic retry.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C05 C08 C12 A02] free gallery, private ownership and revoked public links retain server admission boundaries', async ({ page, owner, playwright }, info) => {
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const original = await createVariant(page.request, owner, 'Recorded owner-private CV');
|
||||
const themes = await json<CvTheme[]>(await page.request.get(`${apiUrl}/cv/themes`));
|
||||
const locked = themes.find(theme => theme.requiresPro && !theme.available)!;
|
||||
expect(locked).toBeDefined();
|
||||
expect((await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
|
||||
data: { name: 'Forbidden premium CV', settings: settings(locked.id) } })).status()).toBe(403);
|
||||
await enter(page, owner);
|
||||
const dialog = await openCreate(page);
|
||||
await dialog.getByRole('button', { name: translations.en.cvEditorAllTemplates, exact: true }).click();
|
||||
const premium = dialog.getByRole('button', { name: locked.name, exact: true });
|
||||
await expect(premium).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(premium).toHaveAttribute('tabindex', '-1');
|
||||
const selectedBefore = await dialog.locator('[aria-pressed="true"][role="button"]').getAttribute('aria-label');
|
||||
expect(selectedBefore).not.toBe(locked.name);
|
||||
await dialog.getByRole('button', { name: translations.en.cancel, exact: true }).click();
|
||||
const foreign = await playwright.request.newContext();
|
||||
const anonymous = await playwright.request.newContext();
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const foreignOwner = await login(foreign, await createOwner(admin, administrator));
|
||||
expect(await json<CvVariantSummary[]>(await foreign.get(`${apiUrl}/cv/variants`))).toEqual([]);
|
||||
const forbidden = [
|
||||
await foreign.get(`${apiUrl}/cv/variants/${original.id}`),
|
||||
await foreign.put(`${apiUrl}/cv/variants/${original.id}`, { headers: foreignOwner.headers, data: { name: 'Forbidden rename', settings: original.settings, source: 'manual', expectedVersion: original.version } }),
|
||||
await foreign.post(`${apiUrl}/cv/variants/${original.id}/duplicate`, { headers: foreignOwner.headers, data: {} }),
|
||||
await foreign.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: foreignOwner.headers, data: { isPublic: true } }),
|
||||
await foreign.post(`${apiUrl}/cv/variants/${original.id}/export-pdf`, { headers: foreignOwner.headers, data: {} }),
|
||||
await foreign.delete(`${apiUrl}/cv/variants/${original.id}`, { headers: foreignOwner.headers }),
|
||||
];
|
||||
expect(forbidden.map(response => response.status())).toEqual([404, 404, 404, 404, 404, 404]);
|
||||
expect(await json<unknown[]>(await foreign.get(`${apiUrl}/cv/variants/${original.id}/versions`))).toEqual([]);
|
||||
expect((await anonymous.get(`${apiUrl}/cv/variants/${original.id}`)).status()).toBe(401);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${original.publicSlug}`)).status()).toBe(404);
|
||||
const published = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
|
||||
const publicResponse = await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`);
|
||||
const publicDocument = await json<{ html: string; name: string }>(publicResponse);
|
||||
expect(publicResponse.headers()['x-robots-tag']).toBe('noindex, nofollow'); expect(publicDocument.html).toContain('CV-specific reviewed title');
|
||||
await page.reload(); await expect(card(page, original.name).getByText(translations.en.cvEditorPublic, { exact: true })).toBeVisible();
|
||||
await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: false } }));
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`)).status()).toBe(404);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`)).status()).toBe(404);
|
||||
const republished = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
|
||||
expect(republished.publicSlug).not.toBe(published.publicSlug);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`)).status()).toBe(404);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`)).status()).toBe(404);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${republished.publicSlug}`)).status()).toBe(200);
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
await info.attach('library-server-admission', { body: JSON.stringify({ originalId: original.id, lockedTheme: locked.id,
|
||||
foreignStatuses: forbidden.map(response => response.status()), publishedSlug: published.publicSlug, republishedSlug: republished.publicSlug,
|
||||
qualification: 'Actual HTTP owner/entitlement, anonymous HTML and revoked-PDF admission only. No valid private/public PDF rendering or output-fidelity claim; public UI migration is a separate checkpoint.' }), contentType: 'application/json' });
|
||||
} finally { await foreign.dispose(); await anonymous.dispose(); await admin.dispose(); }
|
||||
});
|
||||
|
||||
for (const status of [503, 504]) {
|
||||
test(`[P7-04 PRES-C05 C12 E08] private PDF${status} feedback does not retry or change the CV`, async ({ page, owner }, info) => {
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const existing = await createVariant(page.request, owner, `Recorded PDF${status} CV`);
|
||||
let requests = 0;
|
||||
await page.route(`**/api/cv/variants/${existing.id}/export-pdf`, async route => {
|
||||
requests++; return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled PDF unavailable' }) });
|
||||
});
|
||||
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
|
||||
await enter(page, owner, sample);
|
||||
await openAction(page, existing.name, translations.nb.cvEditorDownloadPdf, 'nb');
|
||||
await expect(page.getByRole('alert').filter({ hasText: status === 503 ? translations.nb.cvPdfBusy : translations.nb.cvPdfTimedOut })).toBeVisible();
|
||||
expect(requests).toBe(1);
|
||||
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${existing.id}`))).toEqual(existing);
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
await info.attach(`library-pdf-${status}-presentation`, { body: JSON.stringify({ requests, status,
|
||||
qualification: 'Controlled browser HTTP failure tests presentation/no automatic retry. Exporter is not invoked; this is not PDF output or actual admission qualification.' }), contentType: 'application/json' });
|
||||
});
|
||||
}
|
||||
|
||||
test('[P7-04 PRES-C05 C08 E08] finite library and gallery width locale theme matrix keeps actions and real data reachable', async ({ page, owner }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const sourceBefore = await seedSource(page, owner);
|
||||
const longName = 'Backendutvikler — Åse Nordlys sin eiergjennomgåtte CV for dokumentert profesjonelt arbeid i norske organisasjoner';
|
||||
const longUnbrokenName = 'EiergjennomgåttDokumentertProfesjonellErfaringOgKvalifikasjoner'.repeat(3);
|
||||
const originals = [await createVariant(page.request, owner, longName), await createVariant(page.request, owner, longUnbrokenName)];
|
||||
const samples: Sample[] = [
|
||||
{ language: 'nb', theme: 'light', width: 320, height: 844 },
|
||||
{ language: 'en', theme: 'dark', width: 360, height: 844 },
|
||||
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
|
||||
{ language: 'en', theme: 'light', width: 768, height: 1024 },
|
||||
{ language: 'nb', theme: 'light', width: 1024, height: 768 },
|
||||
{ language: 'en', theme: 'dark', width: 1280, height: 320 },
|
||||
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
|
||||
{ language: 'en', theme: 'light', width: 1920, height: 1080 },
|
||||
];
|
||||
for (const sample of samples) {
|
||||
await enter(page, owner, sample);
|
||||
await expect(card(page, longName, sample.language)).toBeVisible();
|
||||
await expect(card(page, longUnbrokenName, sample.language)).toBeVisible();
|
||||
await actionButton(page, longName, sample.language).focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('menu').getByRole('menuitem', { name: translations[sample.language].cvDashboardRename, exact: true })).toBeVisible();
|
||||
await page.keyboard.press('Escape'); await expect(actionButton(page, longName, sample.language)).toBeFocused();
|
||||
await page.getByRole('heading', { level: 2, name: translations[sample.language].cvDashboardLibraryTitle, exact: true }).scrollIntoViewIfNeeded();
|
||||
await capture(page, info, sample, 'library');
|
||||
const dialog = await openCreate(page, sample.language);
|
||||
await expect(dialog.getByLabel(translations[sample.language].cvEditorName, { exact: false })).toBeFocused();
|
||||
await dialog.getByLabel(translations[sample.language].cvEditorName, { exact: false }).fill(longName);
|
||||
await dialog.getByRole('button', { name: translations[sample.language].cvEditorAllTemplates, exact: true }).click();
|
||||
await capture(page, info, sample, 'gallery');
|
||||
await dialog.getByRole('button', { name: translations[sample.language].cancel, exact: true }).focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('button', { name: translations[sample.language].cvDashboardNewCv, exact: true })).toBeFocused();
|
||||
}
|
||||
expect(await readSource(page.request)).toEqual(sourceBefore);
|
||||
for (const original of originals) expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(original);
|
||||
await info.attach('library-finite-responsive-matrix', { body: JSON.stringify({ samples, originals,
|
||||
qualification: 'Eight balanced samples, not the complete width x theme x locale cross-product. Library and create gallery are captured separately; no document-canvas scrolling, physical touch, actual zoom or human screen-reader acceptance.' }), contentType: 'application/json' });
|
||||
});
|
||||
@@ -12,9 +12,10 @@ type Props = {
|
||||
onSelect: (theme: CvTheme) => void;
|
||||
previewHeight?: number;
|
||||
showDescriptions?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true }: Props) {
|
||||
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const id = useId();
|
||||
const [atsOnly, setAtsOnly] = useState(true);
|
||||
@@ -23,19 +24,20 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
|
||||
return (
|
||||
<Stack spacing={1.25}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Button size="small" aria-pressed={atsOnly} variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
|
||||
<Button size="small" aria-pressed={!atsOnly} variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
|
||||
<Button size="small" disabled={disabled} aria-pressed={atsOnly} variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
|
||||
<Button size="small" disabled={disabled} aria-pressed={!atsOnly} variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
|
||||
{atsOnly && <Typography variant="caption" color="text.secondary">{t("cvEditorAtsTemplateHelp")}</Typography>}
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.25 }}>
|
||||
{visibleThemes.map((theme) => {
|
||||
const active = theme.id === selectedId;
|
||||
const locked = theme.available === false;
|
||||
const unavailable = locked || disabled;
|
||||
return (
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={locked} aria-pressed={active}
|
||||
onClick={() => { if (!locked) onSelect(theme); }}
|
||||
onKeyDown={(event) => { if (!locked && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", position: "relative", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": locked ? undefined : { borderColor: "primary.main" } }}>
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
|
||||
onClick={() => { if (!unavailable) onSelect(theme); }}
|
||||
onKeyDown={(event) => { if (!unavailable && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
|
||||
sx={{ p: 1, cursor: unavailable ? "not-allowed" : "pointer", position: "relative", minWidth: 0, overflowWrap: "anywhere", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": unavailable ? undefined : { borderColor: "primary.main" } }}>
|
||||
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 750 }}>{theme.name}</Typography>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import CvBuilderPage from './views/CvBuilderPage';
|
||||
@@ -9,6 +9,7 @@ import { ToastProvider } from './toast';
|
||||
import { api } from './api';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { emptyCvVariantSettings } from './cvBuilder';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -30,13 +31,14 @@ jest.mock('./api', () => ({
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
function renderPage(extra?: React.ReactNode) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
{extra}
|
||||
<CvBuilderPage />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
@@ -48,11 +50,293 @@ function renderPage() {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any);
|
||||
});
|
||||
|
||||
afterEach(() => window.localStorage.removeItem('uiLanguage'));
|
||||
|
||||
const themes = [
|
||||
{ id: 'modern', name: 'Modern', category: 'Professional', description: '', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] },
|
||||
{ id: 'clean', name: 'Clean', category: 'Professional', description: '', layout: 'single-column', accent: '#444444', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#444444'] },
|
||||
{ id: 'premium', name: 'Premium', category: 'Professional', description: '', layout: 'sidebar', accent: '#333333', photoShape: 'none', supportsIcons: true, atsFriendly: true, requiresPro: true, available: false, swatches: ['#333333'] },
|
||||
];
|
||||
|
||||
const existingCv = {
|
||||
id: 1, name: 'Frontend CV', themeId: 'modern', language: 'en', version: 2,
|
||||
jobApplicationId: 17, jobTitle: 'Frontend Engineer', companyName: 'Northstar',
|
||||
publicSlug: 'original-slug', isPublic: true, updatedAtUtc: '2026-10-12T10:00:00Z',
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function selectAction(name: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' }));
|
||||
const item = await screen.findByRole('menuitem', { name });
|
||||
item.focus();
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
test('the CV library has a section heading and announces its held initial read without showing an empty result', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url) => url === '/cv/variants' ? read.promise : Promise.resolve({ data: themes }) as any);
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your CVs' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
expect(screen.queryByText('No CVs yet')).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => { read.resolve({ data: [existingCv] }); });
|
||||
expect(await screen.findByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Content loaded');
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent(/Loading/i);
|
||||
});
|
||||
|
||||
test('a failed CV list read has an explicit retry that clears the stale failure after recovery', async () => {
|
||||
const retry = deferred<any>();
|
||||
let listReads = 0;
|
||||
mockedApi.get.mockImplementation((url) => url === '/cv/variants'
|
||||
? ++listReads === 1 ? Promise.reject(new Error('synthetic unavailable')) : retry.promise
|
||||
: Promise.resolve({ data: themes }) as any);
|
||||
renderPage();
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load your CVs.');
|
||||
expect(screen.queryByText('No CVs yet')).not.toBeInTheDocument();
|
||||
const retryButton = screen.getByRole('button', { name: 'Retry' });
|
||||
retryButton.focus();
|
||||
fireEvent.click(retryButton);
|
||||
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/cv/variants')).toHaveLength(2));
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => { retry.resolve({ data: [existingCv] }); });
|
||||
expect(await screen.findByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Could not load your CVs.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a live language change retains an open CV name and template draft without refetching library data', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
|
||||
await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'CV name' }), { target: { value: 'My unsaved application CV' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clean' }));
|
||||
const requests = mockedApi.get.mock.calls.length;
|
||||
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
|
||||
expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'CV-navn' })).toHaveValue('My unsaved application CV');
|
||||
expect(screen.getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(requests);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('held CV creation locks the draft and gallery, then keeps a failed draft for an explicit retry', async () => {
|
||||
const write = deferred<any>();
|
||||
mockedApi.post.mockImplementationOnce(() => write.promise).mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
const name = within(dialog).getByRole('textbox', { name: 'CV name' });
|
||||
fireEvent.change(name, { target: { value: 'Application-specific CV' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
expect(name).toBeDisabled();
|
||||
expect(within(dialog).getByRole('button', { name: 'ATS templates' })).toBeDisabled();
|
||||
expect(within(dialog).getByRole('button', { name: 'All templates' })).toBeDisabled();
|
||||
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Creating/ }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Modern' }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await act(async () => { write.reject(new Error('synthetic failed create')); });
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.');
|
||||
expect(name).toBeEnabled();
|
||||
expect(name).toHaveValue('Application-specific CV');
|
||||
expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: emptyCvVariantSettings('clean') }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
|
||||
});
|
||||
|
||||
test('a locked CV template remains identifiable and cannot change the creation request', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
const premium = within(dialog).getByRole('button', { name: 'Premium' });
|
||||
expect(premium).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(premium).toHaveAttribute('tabindex', '-1');
|
||||
fireEvent.click(premium);
|
||||
fireEvent.keyDown(premium, { key: 'Enter' });
|
||||
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: emptyCvVariantSettings('modern') })));
|
||||
});
|
||||
|
||||
test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => {
|
||||
const write = deferred<any>();
|
||||
let listReads = 0;
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants'
|
||||
? ++listReads === 1 ? [existingCv] : [existingCv, { ...existingCv, id: 2, name: 'Frontend CV (copy)', publicSlug: 'copy-slug', isPublic: false }]
|
||||
: themes }) as any);
|
||||
mockedApi.post.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
const actions = screen.getByRole('button', { name: 'Actions for Frontend CV' });
|
||||
await selectAction('Duplicate');
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/1/duplicate', { name: undefined }));
|
||||
expect(actions).toBeDisabled();
|
||||
fireEvent.click(actions);
|
||||
const duplicate = screen.queryByRole('menuitem', { name: 'Duplicate' });
|
||||
if (duplicate) fireEvent.click(duplicate);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(listReads).toBe(1);
|
||||
|
||||
await act(async () => { write.resolve({ data: { id: 2 } }); });
|
||||
expect(await screen.findByRole('link', { name: 'Open Frontend CV (copy)' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(actions).toBeEnabled());
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(listReads).toBe(2);
|
||||
});
|
||||
|
||||
test('rename reads the current revision and preserves its settings when saving the new name', async () => {
|
||||
const settings = { ...emptyCvVariantSettings('clean'), overrides: { summary: { text: 'Owned override' } }, showPageNumbers: true };
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv]
|
||||
: url === '/cv/variants/1' ? { ...existingCv, settings, version: 7 } : themes }) as any);
|
||||
mockedApi.put.mockResolvedValueOnce({ data: { ...existingCv, name: 'Renamed application CV', version: 8, updatedAtUtc: '2026-10-12T11:00:00Z' } } as any);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Rename');
|
||||
const input = await screen.findByRole('textbox', { name: 'Give this CV a clear name.' });
|
||||
fireEvent.change(input, { target: { value: ' Renamed application CV ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/1', { name: 'Renamed application CV', settings, source: 'manual', expectedVersion: 7 }));
|
||||
expect(await screen.findByRole('link', { name: 'Open Renamed application CV' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a rename conflict keeps the original library entry and never retries the write automatically', async () => {
|
||||
const settings = { ...emptyCvVariantSettings('modern'), overrides: { summary: { text: 'Owned override' } } };
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv]
|
||||
: url === '/cv/variants/1' ? { ...existingCv, settings, version: 7 } : themes }) as any);
|
||||
mockedApi.put.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Rename');
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Conflicting CV name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
||||
expect((await screen.findAllByText('Rename failed.')).length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Open Conflicting CV name' })).not.toBeInTheDocument();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cancelling deletion returns focus to the surviving CV action control without removing the CV', async () => {
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
const actions = screen.getByRole('button', { name: 'Actions for Frontend CV' });
|
||||
actions.focus();
|
||||
await selectAction('Delete');
|
||||
await screen.findByRole('heading', { name: 'Delete CV' });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(actions).toHaveFocus());
|
||||
expect(screen.getByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a failed creation keeps its draft and translates its failure after closing and reopening in another language', async () => {
|
||||
mockedApi.post.mockRejectedValueOnce(new Error('synthetic unavailable'));
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New CV' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'CV name' }), { target: { value: 'Keep this failed CV draft' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ny CV' }));
|
||||
const reopened = await screen.findByRole('dialog', { name: 'Opprett en CV' });
|
||||
expect(within(reopened).getByRole('textbox', { name: 'CV-navn' })).toHaveValue('Keep this failed CV draft');
|
||||
expect(within(reopened).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(reopened).getByRole('alert')).toHaveTextContent('Kunne ikke opprette en CV.');
|
||||
expect(within(reopened).queryByText('Could not create a CV.')).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('generic PDF failure follows the current language both after failure and when a held request fails', async () => {
|
||||
const secondDownload = deferred<any>();
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
mockedApi.post.mockRejectedValueOnce(new Error('synthetic unavailable')).mockImplementationOnce(() => secondDownload.promise);
|
||||
renderPage();
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Download PDF');
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('PDF download failed.');
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Nedlasting av PDF mislyktes.');
|
||||
expect(screen.queryByText('PDF download failed.')).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Handlinger for Frontend CV' }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Last ned PDF' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'en' })); });
|
||||
await act(async () => { secondDownload.reject(new Error('synthetic unavailable')); });
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('PDF download failed.');
|
||||
expect(screen.queryByText('Nedlasting av PDF mislyktes.')).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a held deletion preserves focus moved to another surviving control before the response arrives', async () => {
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||
mockedApi.delete.mockImplementationOnce(() => write.promise);
|
||||
renderPage(<button type="button">Separate workflow control</button>);
|
||||
await screen.findByText('Frontend CV');
|
||||
await selectAction('Delete');
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete CV' }));
|
||||
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith('/cv/variants/1'));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
const otherControl = screen.getByRole('button', { name: 'Separate workflow control' });
|
||||
otherControl.focus();
|
||||
expect(otherControl).toHaveFocus();
|
||||
await act(async () => { write.resolve({ data: null }); });
|
||||
await waitFor(() => expect(screen.queryByRole('link', { name: 'Open Frontend CV' })).not.toBeInTheDocument());
|
||||
expect(otherControl).toHaveFocus();
|
||||
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[503, 'PDF generation is busy. Please try again shortly.'],
|
||||
[504, 'PDF generation took too long. Please try again.'],
|
||||
@@ -113,7 +397,7 @@ test('CV dashboard and create flow render in Bokmål', async () => {
|
||||
expect(await screen.findByText('Ingen CV-er ennå')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Ny CV' })[0]);
|
||||
expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('CV-navn')).toHaveValue('CV uten tittel');
|
||||
expect(screen.getByLabelText(/^CV-navn/)).toHaveValue('CV uten tittel');
|
||||
expect(screen.getByText('Velg en mal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -784,6 +784,7 @@ export const translations = {
|
||||
cvEditorRestoredVersion: "Restored version {version}.",
|
||||
cvEditorRestoreFailed: "Restore failed.",
|
||||
cvDashboardTitle: "CV Builder",
|
||||
cvDashboardLibraryTitle: "Your CVs",
|
||||
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
|
||||
cvDashboardUntitled: "Untitled CV",
|
||||
cvDashboardLoadFailed: "Could not load your CVs.",
|
||||
@@ -816,6 +817,7 @@ export const translations = {
|
||||
cvDashboardEnterName: "Enter a name.",
|
||||
cvDashboardNameExample: "For example: Backend Engineer — Acme",
|
||||
cvDashboardChooseTemplate: "Choose a template",
|
||||
cvDashboardDefaultTemplate: "Modern",
|
||||
cvDashboardCreating: "Creating…",
|
||||
cvDashboardCreateCv: "Create CV",
|
||||
cvAiFailed: "AI suggestion failed.",
|
||||
@@ -3416,6 +3418,7 @@ export const translations = {
|
||||
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
|
||||
cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
|
||||
cvDashboardTitle: "CV-bygger",
|
||||
cvDashboardLibraryTitle: "Dine CV-er",
|
||||
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
|
||||
cvDashboardUntitled: "CV uten tittel",
|
||||
cvDashboardLoadFailed: "Kunne ikke laste CV-ene dine.",
|
||||
@@ -3448,6 +3451,7 @@ export const translations = {
|
||||
cvDashboardEnterName: "Skriv inn et navn.",
|
||||
cvDashboardNameExample: "For eksempel: Backendutvikler — Acme",
|
||||
cvDashboardChooseTemplate: "Velg en mal",
|
||||
cvDashboardDefaultTemplate: "Moderne",
|
||||
cvDashboardCreating: "Oppretter…",
|
||||
cvDashboardCreateCv: "Opprett CV",
|
||||
cvAiFailed: "AI-forslaget mislyktes.",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
|
||||
@@ -16,98 +15,126 @@ import { useDialogActions } from "../dialogs";
|
||||
import CvTemplateGallery from "../components/CvTemplateGallery";
|
||||
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
|
||||
export default function CvBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { language, t } = useI18n();
|
||||
const localeRef = useRef(t);
|
||||
localeRef.current = t;
|
||||
const { confirmAction, promptForValue } = useDialogActions();
|
||||
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resource = useViewResource(async () => ({
|
||||
variants: await cvBuilderApi.list(),
|
||||
themes: await cvBuilderApi.themes().catch(() => []),
|
||||
}), { initialData: { variants: [] as CvVariantSummary[], themes: [] as CvTheme[] }, errorMessage: "" });
|
||||
const { variants, themes } = resource.data;
|
||||
const loading = resource.loading || resource.refreshing;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const createTitleRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionBusy = useRef(false);
|
||||
const actionAnchor = useRef<HTMLElement | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null);
|
||||
const [createError, setCreateError] = useState<{ error: unknown } | null>(null);
|
||||
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
|
||||
const [themes, setThemes] = useState<CvTheme[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
|
||||
const [newTheme, setNewTheme] = useState("modern");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setVariants(await cvBuilderApi.list());
|
||||
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t("cvDashboardLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
if (pending) contentRef.current?.focus();
|
||||
else if (document.activeElement === contentRef.current && actionAnchor.current?.isConnected) actionAnchor.current.focus();
|
||||
}, [pending]);
|
||||
|
||||
const createNew = async () => {
|
||||
const startAction = (showPending = true) => {
|
||||
if (actionBusy.current) return false;
|
||||
actionBusy.current = true;
|
||||
actionAnchor.current = menu?.anchor ?? null;
|
||||
setMenu(null);
|
||||
setActionError(null);
|
||||
if (showPending) setPending(true);
|
||||
return true;
|
||||
};
|
||||
const finishAction = () => { actionBusy.current = false; setPending(false); };
|
||||
const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => {
|
||||
resource.setData((data) => ({ ...data, variants: update(data.variants) }));
|
||||
};
|
||||
|
||||
const createNew = () => {
|
||||
if (actionBusy.current) return;
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const confirmCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
if (!newName.trim() || actionBusy.current) return;
|
||||
actionBusy.current = true;
|
||||
createTitleRef.current?.focus();
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
|
||||
setCreateOpen(false);
|
||||
navigate(`/career/builder/${variant.id}`);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvDashboardCreateFailed")), "error");
|
||||
} finally { setCreating(false); }
|
||||
setCreateError({ error: err });
|
||||
} finally { actionBusy.current = false; setCreating(false); }
|
||||
};
|
||||
|
||||
const duplicate = async (id: number) => {
|
||||
if (!startAction()) return;
|
||||
try {
|
||||
await cvBuilderApi.duplicate(id);
|
||||
await load();
|
||||
toast(t("cvDashboardDuplicated"), "success");
|
||||
await resource.reload();
|
||||
toast(localeRef.current("cvDashboardDuplicated"), "success");
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvDashboardDuplicateFailed")), "error");
|
||||
setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
|
||||
} finally {
|
||||
setMenu(null);
|
||||
finishAction();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!startAction(false)) return;
|
||||
const variant = variants.find((item) => item.id === id);
|
||||
setMenu(null);
|
||||
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
||||
title: t("cvDashboardDeleteCv"),
|
||||
confirmLabel: t("cvDashboardDeleteCv"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
try {
|
||||
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
||||
title: t("cvDashboardDeleteCv"),
|
||||
confirmLabel: t("cvDashboardDeleteCv"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
setPending(true);
|
||||
await cvBuilderApi.remove(id);
|
||||
setVariants((v) => v.filter((x) => x.id !== id));
|
||||
toast(t("cvDashboardDeleted"), "success");
|
||||
toast(localeRef.current("cvDashboardDeleted"), "success");
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvDashboardDeleteFailed")), "error");
|
||||
setActionError({ error: err, key: "cvDashboardDeleteFailed" });
|
||||
} finally {
|
||||
setMenu(null);
|
||||
finishAction();
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (id: number) => {
|
||||
if (!startAction(false)) return;
|
||||
const current = variants.find((item) => item.id === id);
|
||||
setMenu(null);
|
||||
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
try {
|
||||
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
setPending(true);
|
||||
const variant = await cvBuilderApi.get(id);
|
||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
|
||||
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
||||
toast(t("cvDashboardRenamed"), "success");
|
||||
} catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); }
|
||||
toast(localeRef.current("cvDashboardRenamed"), "success");
|
||||
} catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
|
||||
finally { finishAction(); }
|
||||
};
|
||||
|
||||
const download = async (id: number, cvName: string) => {
|
||||
setMenu(null);
|
||||
if (!startAction()) return;
|
||||
try {
|
||||
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
@@ -118,42 +145,38 @@ export default function CvBuilderPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error");
|
||||
}
|
||||
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
|
||||
} finally { finishAction(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("cvDashboardTitle")}</Typography>
|
||||
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>{t("cvDashboardSubtitle")}</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
{!loading && variants.length === 0 && !error && (
|
||||
<Paper sx={{ p: 4, borderRadius: 4, textAlign: "center" }}>
|
||||
<DescriptionOutlinedIcon sx={{ fontSize: 48, color: "text.disabled" }} />
|
||||
<Typography sx={{ mt: 1, fontWeight: 700 }}>{t("cvDashboardEmptyTitle")}</Typography>
|
||||
<Box ref={contentRef} role="region" aria-label={t("cvDashboardTitle")} tabIndex={-1} sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<PageHeader level="h2" title={t("cvDashboardLibraryTitle")} description={t("cvDashboardSubtitle")}
|
||||
actions={<Button variant="contained" disabled={pending || loading} startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>} />
|
||||
<ViewStateNotice title={t("cvDashboardLoadFailed")} loading={loading}
|
||||
error={resource.error ? { ...resource.error, message: resource.error.message || t("cvDashboardLoadFailed") } : null}
|
||||
onRetry={() => { if (actionBusy.current || loading) return; contentRef.current?.focus(); void resource.reload(); }} compact />
|
||||
{actionError && <Alert severity="error">{getApiErrorMessage(actionError.error, t(actionError.key))}</Alert>}
|
||||
{pending && <Typography role="status" variant="body2">{t("loading")}</Typography>}
|
||||
{!loading && variants.length === 0 && !resource.error && (
|
||||
<Paper variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1 }}>
|
||||
<Typography component="h2" variant="sectionTitle">{t("cvDashboardEmptyTitle")}</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardEmptyBody")}</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
<Box sx={{ display: "grid", minWidth: 0, gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
{variants.map((v) => (
|
||||
<Paper
|
||||
key={v.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-disabled={pending}
|
||||
aria-label={t("cvDashboardOpenNamed", { name: v.name })}
|
||||
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
onClick={() => navigate(`/career/builder/${v.id}`)}
|
||||
sx={{ p: "var(--app-card-padding)", minWidth: 0, borderRadius: 1, cursor: pending ? "default" : "pointer", border: "1px solid", borderColor: "divider", "&:hover": { borderColor: "primary.main" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
onClick={() => { if (!actionBusy.current) navigate(`/career/builder/${v.id}`); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||
if (!actionBusy.current && event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
navigate(`/career/builder/${v.id}`);
|
||||
}
|
||||
@@ -161,12 +184,12 @@ export default function CvBuilderPage() {
|
||||
>
|
||||
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
||||
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
|
||||
<IconButton size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mt: 1, minWidth: 0 }}>{v.name}</Typography>
|
||||
<IconButton disabled={pending} aria-haspopup="menu" size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); if (!actionBusy.current) setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ mt: 1, minWidth: 0 }}>
|
||||
<Chip size="small" label={v.themeId} />
|
||||
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
|
||||
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label={t("cvEditorPublic")} />}
|
||||
@@ -187,13 +210,14 @@ export default function CvBuilderPage() {
|
||||
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>{t("cvDashboardDelete")}</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
|
||||
<DialogTitle id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
|
||||
<TextField autoFocus fullWidth label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
|
||||
<CvTemplateGallery themes={themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => setNewTheme(theme.id)} previewHeight={125} showDescriptions={false} />
|
||||
<Dialog open={createOpen} onClose={() => { if (!actionBusy.current) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title" aria-describedby={`create-cv-help${createError ? " create-cv-error" : ""}`}>
|
||||
<DialogTitle ref={createTitleRef} tabIndex={-1} id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
|
||||
<DialogContent role="region" aria-labelledby="create-cv-title" tabIndex={creating ? 0 : -1}>
|
||||
<Typography id="create-cv-help" color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
|
||||
{createError && <Alert id="create-cv-error" severity="error" sx={{ mb: 2 }}>{getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}</Alert>}
|
||||
<TextField autoFocus fullWidth disabled={creating} required label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
|
||||
<CvTemplateGallery disabled={creating} themes={themes.length ? themes : [{ id: "modern", name: t("cvDashboardDefaultTemplate"), category: t("cvEditorTemplate"), description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} />
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user