Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7db3b4a3b6 | |||
| de2fb45221 | |||
| cef4540456 | |||
| 2cd561d71d | |||
| dbae196291 | |||
| f807d46ff0 |
@@ -0,0 +1,22 @@
|
|||||||
|
# CV library action lifetime
|
||||||
|
|
||||||
|
11 November 2026. Source commit `de2fb452`, based on `cef45404`, branch `codex/a3-ui-10`.
|
||||||
|
|
||||||
|
The library allowed a create response to navigate after unmount and a PDF response to trigger a download after leaving or session invalidation. Confirmed delete and a rename read could also continue into a write after session loss. Duplicate completion could start another read and announce success in the stale context.
|
||||||
|
|
||||||
|
Reused the existing session-boundary cookie/context/invalidation functions and the BackupCard mount-generation pattern. Guarded profile-outline completion, create navigation, duplicate reload/feedback, deletion confirmation/completion, rename prompt/read/write completion, PDF download and asynchronous error decoding. Existing action locking, focus, drafts, current-language feedback, revision payloads and confirmations remain. No JSX, route, schema, canonical profile, renderer or PDF style changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- RED: seven new deferred-response cases failed on the original source; 24 other library cases were filtered out. The first wrapper had environment-assignment errors before Jest; Jest still executed and produced the seven expected assertion failures. Its raw receipt is retained, not presented as a clean command execution.
|
||||||
|
- GREEN: 85 distinct cases across five suites passed, no skips: CV library 35, CV API helpers 15, session boundary 15, job-export lifetime 16, backup lifetime four. This includes 11 added library cases. The earlier 31-case library pass overlaps this result.
|
||||||
|
- TypeScript `tsc --noEmit --incremental false`: exit 0. Scoped ESLint on the two changed files: exit 0. `git diff --check`: passed.
|
||||||
|
- Tests use real React/MUI dialogs with mocked API responses and controlled boundary signals. Existing session-boundary suite checks the actual boundary implementation separately. Active PDF test uses synthetic bytes and observes filename and URL revocation.
|
||||||
|
|
||||||
|
## Limits and preservation
|
||||||
|
|
||||||
|
No fresh export build/native browser journey, real API/session/cookie-switch integration, physical PDF or accessibility acceptance is claimed. The previous export is stale for this frontend change. No cancellation or rollback of an already accepted server write is promised; stale completions are discarded. Initial library reads remain owned by the existing useViewResource/API path, which is outside this fix. All final A3, security, Phase 10 and release gates remain open. AttachmentStorage destination-ownership race remains source-backed and unproven; it was not changed.
|
||||||
|
|
||||||
|
Evidence is under [cv-library-lifetime](evidence/cv-library-lifetime/SOURCE_RECEIPTS.json). Normalised source hashes and raw receipt hashes bind this bounded result.
|
||||||
|
|
||||||
|
AST graph refresh PASSED on an exact 1,003-file tracked-code snapshot on E: (E:/Storage/Projects/Active/temp docs/jobjakt-local-build/a3-cv-library-source). SOURCE_MANIFEST.json binds files to source commit de2fb452; 10,345 nodes and 36,111 edges. 51 no-symbol warnings retained. Code-only snapshot excludes documentation. Stale generated integration graph: all 2,721 files (97,063,540 bytes) preserved and SHA256-verified on E before replacing its C directory with a junction; no tracked source or evidence removed.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"sourceCommit": "de2fb452210b128a8b5a3729e5971b8e2ddbba7c",
|
||||||
|
"sourcesLfSha256": {
|
||||||
|
"job-tracker-ui/src/views/CvBuilderPage.tsx": "63eb7348efcf4aaf5f3cb7e5d98fe6c5223896ac7ad64caa4111e251135f162e",
|
||||||
|
"job-tracker-ui/src/cv-builder-page.test.tsx": "5871bfef73499f2c7b2d9424e245436ded8f2e0838c4d5e6c08b49525f9822b7",
|
||||||
|
"job-tracker-ui/src/sessionBoundary.ts": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9",
|
||||||
|
"job-tracker-ui/src/cvBuilder.ts": "c021bafeabbc8ad3526d1b10b90ad8f7f0958ded8ed26eac8c1d28b53098b64d",
|
||||||
|
"job-tracker-ui/jest.config.cjs": "de323cd3aa8693b11a028b2c026682f05ab988d182ed860861a051d66e53c286"
|
||||||
|
},
|
||||||
|
"evidenceSha256": {
|
||||||
|
"green.txt": "cdd95bb125faddbc952d3b3588b3329eb8100dda6c6322b04b10ae150393230f",
|
||||||
|
"lint.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"red.txt": "658bccf93a58b3ea639635ccc33810ef7b87163c45ab91425673d0b50afec9c1",
|
||||||
|
"results.json": "f9c9dccfd13445d19aa803467ebd3aa3410a2b10054b278b22dfa90709224c8a",
|
||||||
|
"SOURCE_RECEIPTS.json": "609aade9b200454fc4bf05cc7913615d2faba6718e2a64b74e1533f1311db183",
|
||||||
|
"types.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"graph.txt": "b2b80b86f868b619d66445bd6d71d750ca091f8d6bee5dc40758fade72f22a67"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Re-extracting code files in . (no LLM needed)...
|
||||||
|
AST cache: 0/1004 files served from cache (0%); 1004 to extract
|
||||||
|
AST extraction: 100/1004 uncached files (9%) [16 workers]
|
||||||
|
AST extraction: 200/1004 uncached files (19%) [16 workers]
|
||||||
|
AST extraction: 300/1004 uncached files (29%) [16 workers]
|
||||||
|
AST extraction: 400/1004 uncached files (39%) [16 workers]
|
||||||
|
AST extraction: 500/1004 uncached files (49%) [16 workers]
|
||||||
|
AST extraction: 600/1004 uncached files (59%) [16 workers]
|
||||||
|
AST extraction: 700/1004 uncached files (69%) [16 workers]
|
||||||
|
AST extraction: 800/1004 uncached files (79%) [16 workers]
|
||||||
|
AST extraction: 900/1004 uncached files (89%) [16 workers]
|
||||||
|
AST extraction: 1000/1004 uncached files (99%) [16 workers]
|
||||||
|
AST extraction: 1004/1004 uncached files (100%) [16 workers]
|
||||||
|
warning: 8 .sql file(s) contributed nothing to the graph because a dependency is missing: tree_sitter_sql not installed. Install it with: pip install "graphifyy[sql]" (#1745)
|
||||||
|
warning: 51 code file(s) yielded no symbols (0.12 MB) — a data file, or source this extractor models no symbols for: JobTrackerApi.Tests/PdfBrowserStub/Program.cs, JobTrackerApi/Program.cs, JobTrackerApi/Properties/AssemblyInfo.cs, browser-extension/tests/capture-url.test.mjs, docs/cv-import/benchmarks/harness/Program.cs (+46 more)
|
||||||
|
Graph has 10345 nodes (above 5000 limit). Building aggregated community view...
|
||||||
|
graph.html written (aggregated: 273 community nodes, 2404 cross-community edges)
|
||||||
|
Tip: run with --obsidian for full node-level detail.
|
||||||
|
[graphify watch] Rebuilt: 10345 nodes, 36111 edges, 273 communities
|
||||||
|
[graphify watch] graph.json, graph.html and GRAPH_REPORT.md updated in graphify-out
|
||||||
|
Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.
|
||||||
|
Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
Test Suites: 5 passed, 5 total
|
||||||
|
Tests: 85 passed, 85 total
|
||||||
|
Snapshots: 0 total
|
||||||
|
Time: 25.539 s
|
||||||
|
Ran all test suites matching src/cv-builder-page.test.tsx|src/cvBuilder.test.ts|src/session-boundary.test.ts|src/backup-lifetime.test.tsx|src/job-export-lifetime.test.tsx.
|
||||||
|
Test results written to: E:\Storage\Projects\Active\temp docs\jobjakt-local-build\cv-library-results.json
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -13,6 +13,13 @@ import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder';
|
|||||||
import { translations } from './i18n/translations';
|
import { translations } from './i18n/translations';
|
||||||
|
|
||||||
const mockNavigate = jest.fn();
|
const mockNavigate = jest.fn();
|
||||||
|
let mockSessionActive = true;
|
||||||
|
jest.mock('./sessionBoundary', () => ({
|
||||||
|
...jest.requireActual('./sessionBoundary'),
|
||||||
|
checkSessionCookie: jest.fn(),
|
||||||
|
getSessionContext: () => 'cv-owner',
|
||||||
|
isSessionInvalidated: () => !mockSessionActive,
|
||||||
|
}));
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useNavigate: () => mockNavigate,
|
useNavigate: () => mockNavigate,
|
||||||
@@ -50,6 +57,7 @@ function renderPage(extra?: React.ReactNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockSessionActive = true;
|
||||||
window.localStorage.removeItem('uiLanguage');
|
window.localStorage.removeItem('uiLanguage');
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any);
|
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any);
|
||||||
@@ -94,6 +102,140 @@ async function selectAction(name: string) {
|
|||||||
fireEvent.click(item);
|
fireEvent.click(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.each(['unmount', 'session'] as const)('late CV creation cannot navigate after %s', async boundary => {
|
||||||
|
const write = deferred<any>();
|
||||||
|
mockedApi.post.mockImplementationOnce(() => write.promise);
|
||||||
|
const view = renderPage();
|
||||||
|
await screen.findByText('No CVs yet');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'New CV' }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Create CV' }));
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||||
|
if (boundary === 'unmount') view.unmount();
|
||||||
|
else mockSessionActive = false;
|
||||||
|
await act(async () => { write.resolve({ data: { id: 42 } }); });
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['unmount', 'session'] as const)('late CV PDF cannot download after %s', async boundary => {
|
||||||
|
const read = deferred<any>();
|
||||||
|
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
mockedApi.post.mockImplementationOnce(() => read.promise);
|
||||||
|
const createUrl = jest.fn(() => 'blob:synthetic-cv');
|
||||||
|
const oldCreate = URL.createObjectURL;
|
||||||
|
URL.createObjectURL = createUrl;
|
||||||
|
try {
|
||||||
|
const view = renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Download PDF');
|
||||||
|
if (boundary === 'unmount') view.unmount();
|
||||||
|
else mockSessionActive = false;
|
||||||
|
await act(async () => { read.resolve({ data: new Blob(['synthetic']) }); });
|
||||||
|
expect(createUrl).not.toHaveBeenCalled();
|
||||||
|
} finally { URL.createObjectURL = oldCreate; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session invalidation while confirming deletion prevents the write', async () => {
|
||||||
|
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
mockedApi.delete.mockResolvedValue({ data: null } as any);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Delete');
|
||||||
|
const button = await screen.findByRole('button', { name: 'Delete CV' });
|
||||||
|
mockSessionActive = false;
|
||||||
|
await act(async () => { fireEvent.click(button); });
|
||||||
|
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session invalidation during the rename read prevents a later revisioned write', async () => {
|
||||||
|
const read = deferred<any>();
|
||||||
|
mockedApi.get.mockImplementation(url => url === '/cv/variants/1' ? read.promise : Promise.resolve({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Rename');
|
||||||
|
fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Changed CV' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
||||||
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants/1'));
|
||||||
|
mockSessionActive = false;
|
||||||
|
await act(async () => { read.resolve({ data: { ...existingCv, settings: emptyCvVariantSettings('modern') } }); });
|
||||||
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a duplicate completing in an invalid session neither reloads nor announces success', async () => {
|
||||||
|
const write = deferred<any>();
|
||||||
|
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
mockedApi.post.mockImplementationOnce(() => write.promise);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Duplicate');
|
||||||
|
const reads = mockedApi.get.mock.calls.length;
|
||||||
|
mockSessionActive = false;
|
||||||
|
await act(async () => { write.resolve({ data: { id: 2 } }); });
|
||||||
|
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||||
|
expect(screen.queryByText(translations.en.cvDashboardDuplicated)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an active CV PDF download retains its filename and releases the synthetic URL', async () => {
|
||||||
|
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
const blob = new Blob(['synthetic PDF bytes']);
|
||||||
|
mockedApi.post.mockResolvedValueOnce({ data: blob } as any);
|
||||||
|
const oldCreate = URL.createObjectURL;
|
||||||
|
const oldRevoke = URL.revokeObjectURL;
|
||||||
|
const create = jest.fn(() => 'blob:synthetic-cv');
|
||||||
|
const revoke = jest.fn();
|
||||||
|
URL.createObjectURL = create;
|
||||||
|
URL.revokeObjectURL = revoke;
|
||||||
|
const downloads: string[] = [];
|
||||||
|
const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download); });
|
||||||
|
try {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Download PDF');
|
||||||
|
await waitFor(() => expect(downloads).toEqual(['Frontend CV.pdf']));
|
||||||
|
expect(create).toHaveBeenCalledWith(blob);
|
||||||
|
expect(revoke).toHaveBeenCalledWith('blob:synthetic-cv');
|
||||||
|
} finally { URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; click.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session invalidation while naming a CV prevents the rename read and write', async () => {
|
||||||
|
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Rename');
|
||||||
|
fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Changed CV' } });
|
||||||
|
mockSessionActive = false;
|
||||||
|
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Rename' })); });
|
||||||
|
expect(mockedApi.get).not.toHaveBeenCalledWith('/cv/variants/1');
|
||||||
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an export error decoded after session invalidation cannot show delayed feedback', async () => {
|
||||||
|
const text = deferred<string>();
|
||||||
|
const body = new Blob();
|
||||||
|
Object.defineProperty(body, 'text', { value: () => text.promise });
|
||||||
|
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
||||||
|
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: body } });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Frontend CV');
|
||||||
|
await selectAction('Download PDF');
|
||||||
|
mockSessionActive = false;
|
||||||
|
await act(async () => { text.resolve('{"code":"cv_empty_content"}'); });
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a profile outline completing after session invalidation cannot populate the create draft', async () => {
|
||||||
|
const read = deferred<any>();
|
||||||
|
mockedApi.get.mockImplementation(url => url === '/cv/outline' ? read.promise : Promise.resolve({ data: url === '/cv/themes' ? themes : [] }) as any);
|
||||||
|
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.click(within(dialog).getByRole('radio', { name: 'From Career Profile' }));
|
||||||
|
mockSessionActive = false;
|
||||||
|
await act(async () => { read.resolve({ data: { sections: [{ key: 'skills', title: 'Stale profile skills' }] } }); });
|
||||||
|
expect(within(dialog).queryByRole('checkbox', { name: 'Stale profile skills' })).not.toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole('button', { name: 'Create CV' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
test('blank creation explicitly excludes all profile sections and contact even after template selection', async () => {
|
test('blank creation explicitly excludes all profile sections and contact even after template selection', async () => {
|
||||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { TranslationKey } from "../i18n/translations";
|
|||||||
import { PageHeader } from "../layout/PageLayout";
|
import { PageHeader } from "../layout/PageLayout";
|
||||||
import ViewStateNotice from "../components/ViewStateNotice";
|
import ViewStateNotice from "../components/ViewStateNotice";
|
||||||
import { useViewResource } from "../hooks/useViewResource";
|
import { useViewResource } from "../hooks/useViewResource";
|
||||||
|
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
|
||||||
|
|
||||||
export default function CvBuilderPage() {
|
export default function CvBuilderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -36,6 +37,7 @@ export default function CvBuilderPage() {
|
|||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const createTitleRef = useRef<HTMLHeadingElement>(null);
|
const createTitleRef = useRef<HTMLHeadingElement>(null);
|
||||||
const actionBusy = useRef(false);
|
const actionBusy = useRef(false);
|
||||||
|
const session = useRef({ mounted: true, generation: 0 });
|
||||||
const actionAnchor = useRef<HTMLElement | null>(null);
|
const actionAnchor = useRef<HTMLElement | null>(null);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null);
|
const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null);
|
||||||
@@ -52,12 +54,33 @@ export default function CvBuilderPage() {
|
|||||||
const [selectedSections, setSelectedSections] = useState<string[]>([]);
|
const [selectedSections, setSelectedSections] = useState<string[]>([]);
|
||||||
const [includeContact, setIncludeContact] = useState(false);
|
const [includeContact, setIncludeContact] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const owner = session.current;
|
||||||
|
owner.mounted = true;
|
||||||
|
owner.generation += 1;
|
||||||
|
return () => { owner.mounted = false; owner.generation += 1; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const captureSession = () => {
|
||||||
|
checkSessionCookie();
|
||||||
|
const owner = session.current;
|
||||||
|
if (!owner.mounted || isSessionInvalidated()) return null;
|
||||||
|
const context = getSessionContext();
|
||||||
|
const generation = owner.generation;
|
||||||
|
return () => {
|
||||||
|
checkSessionCookie();
|
||||||
|
return owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
|
const active = captureSession();
|
||||||
|
if (!active) return;
|
||||||
setProfileLoading(true);
|
setProfileLoading(true);
|
||||||
setProfileFailed(false);
|
setProfileFailed(false);
|
||||||
try { setProfileOutline(await cvBuilderApi.outline()); }
|
try { const outline = await cvBuilderApi.outline(); if (active()) setProfileOutline(outline); }
|
||||||
catch { setProfileFailed(true); }
|
catch { if (active()) setProfileFailed(true); }
|
||||||
finally { setProfileLoading(false); }
|
finally { if (active()) setProfileLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,15 +89,16 @@ export default function CvBuilderPage() {
|
|||||||
}, [pending]);
|
}, [pending]);
|
||||||
|
|
||||||
const startAction = (showPending = true) => {
|
const startAction = (showPending = true) => {
|
||||||
if (actionBusy.current) return false;
|
const active = captureSession();
|
||||||
|
if (!active || actionBusy.current) return null;
|
||||||
actionBusy.current = true;
|
actionBusy.current = true;
|
||||||
actionAnchor.current = menu?.anchor ?? null;
|
actionAnchor.current = menu?.anchor ?? null;
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
if (showPending) setPending(true);
|
if (showPending) setPending(true);
|
||||||
return true;
|
return active;
|
||||||
};
|
};
|
||||||
const finishAction = () => { actionBusy.current = false; setPending(false); };
|
const finishAction = (active: () => boolean) => { actionBusy.current = false; if (active()) setPending(false); };
|
||||||
const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => {
|
const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => {
|
||||||
resource.setData((data) => ({ ...data, variants: update(data.variants) }));
|
resource.setData((data) => ({ ...data, variants: update(data.variants) }));
|
||||||
};
|
};
|
||||||
@@ -85,7 +109,8 @@ export default function CvBuilderPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmCreate = async () => {
|
const confirmCreate = async () => {
|
||||||
if (!newName.trim() || actionBusy.current) return;
|
const active = captureSession();
|
||||||
|
if (!active || !newName.trim() || actionBusy.current) return;
|
||||||
actionBusy.current = true;
|
actionBusy.current = true;
|
||||||
createTitleRef.current?.focus();
|
createTitleRef.current?.focus();
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
@@ -97,65 +122,76 @@ export default function CvBuilderPage() {
|
|||||||
includeProfileContact: contentSource === "profile" && includeContact,
|
includeProfileContact: contentSource === "profile" && includeContact,
|
||||||
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
|
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
|
||||||
} });
|
} });
|
||||||
|
if (!active()) return;
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
navigate(`/career/builder/${variant.id}`);
|
navigate(`/career/builder/${variant.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError({ error: err });
|
if (active()) setCreateError({ error: err });
|
||||||
} finally { actionBusy.current = false; setCreating(false); }
|
} finally { actionBusy.current = false; if (active()) setCreating(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicate = async (id: number) => {
|
const duplicate = async (id: number) => {
|
||||||
if (!startAction()) return;
|
const active = startAction();
|
||||||
|
if (!active) return;
|
||||||
try {
|
try {
|
||||||
await cvBuilderApi.duplicate(id);
|
await cvBuilderApi.duplicate(id);
|
||||||
|
if (!active()) return;
|
||||||
await resource.reload();
|
await resource.reload();
|
||||||
|
if (!active()) return;
|
||||||
toast(localeRef.current("cvDashboardDuplicated"), "success");
|
toast(localeRef.current("cvDashboardDuplicated"), "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
|
if (active()) setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
|
||||||
} finally {
|
} finally {
|
||||||
finishAction();
|
finishAction(active);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: number) => {
|
const remove = async (id: number) => {
|
||||||
if (!startAction(false)) return;
|
const active = startAction(false);
|
||||||
|
if (!active) return;
|
||||||
const variant = variants.find((item) => item.id === id);
|
const variant = variants.find((item) => item.id === id);
|
||||||
try {
|
try {
|
||||||
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
|
||||||
title: t("cvDashboardDeleteCv"),
|
title: t("cvDashboardDeleteCv"),
|
||||||
confirmLabel: t("cvDashboardDeleteCv"),
|
confirmLabel: t("cvDashboardDeleteCv"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}))) return;
|
})) || !active()) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
await cvBuilderApi.remove(id);
|
await cvBuilderApi.remove(id);
|
||||||
|
if (!active()) return;
|
||||||
setVariants((v) => v.filter((x) => x.id !== id));
|
setVariants((v) => v.filter((x) => x.id !== id));
|
||||||
toast(localeRef.current("cvDashboardDeleted"), "success");
|
toast(localeRef.current("cvDashboardDeleted"), "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError({ error: err, key: "cvDashboardDeleteFailed" });
|
if (active()) setActionError({ error: err, key: "cvDashboardDeleteFailed" });
|
||||||
} finally {
|
} finally {
|
||||||
finishAction();
|
finishAction(active);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rename = async (id: number) => {
|
const rename = async (id: number) => {
|
||||||
if (!startAction(false)) return;
|
const active = startAction(false);
|
||||||
|
if (!active) return;
|
||||||
const current = variants.find((item) => item.id === id);
|
const current = variants.find((item) => item.id === id);
|
||||||
try {
|
try {
|
||||||
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
|
||||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
if (!active() || !nextName?.trim() || nextName.trim() === current?.name) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const variant = await cvBuilderApi.get(id);
|
const variant = await cvBuilderApi.get(id);
|
||||||
|
if (!active()) return;
|
||||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
|
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
|
||||||
|
if (!active()) return;
|
||||||
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
||||||
toast(localeRef.current("cvDashboardRenamed"), "success");
|
toast(localeRef.current("cvDashboardRenamed"), "success");
|
||||||
} catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
|
} catch (err) { if (active()) setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
|
||||||
finally { finishAction(); }
|
finally { finishAction(active); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const download = async (id: number, cvName: string) => {
|
const download = async (id: number, cvName: string) => {
|
||||||
if (!startAction()) return;
|
const active = startAction();
|
||||||
|
if (!active) return;
|
||||||
try {
|
try {
|
||||||
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
|
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
|
||||||
|
if (!active()) return;
|
||||||
const url = URL.createObjectURL(response.data as Blob);
|
const url = URL.createObjectURL(response.data as Blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -164,8 +200,8 @@ export default function CvBuilderPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const key = await getCvExportFailureKey(err);
|
const key = await getCvExportFailureKey(err);
|
||||||
setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
|
if (active()) setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
|
||||||
} finally { finishAction(); }
|
} finally { finishAction(active); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user