feat(career): CV Builder UI — 3-tab builder, live preview, variants, public CV
Frontend for Phase 4. /career/builder lists CV variants; the editor has the
three spec tabs (Content / Customize / AI Tools, plus History) beside an
always-on live preview that re-renders through the server theme engine on a
debounce. Content: reorder/hide/rename sections, headline override, custom
sections. Customize: 8-theme picker, accent colour, fonts, density, page size,
photo/icons/page-number toggles. AI Tools: suggestion-only assistance (never
auto-applied). Autosave with version history + restore, public on/off with a
copyable /cv/{slug} link, PDF export. Public read-only page at /cv/:slug.
- cvBuilder.ts (types + API), CvBuilderPage, CvBuilderEditor, PublicCvPage
- routes + nav wired in App.tsx; "Open CV Builder" entry on Career Workspace
- 2 component tests; tsc + production build clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import CvBuilderPage from './views/CvBuilderPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { ToastProvider } from './toast';
|
||||
import { api } from './api';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_error: any, fallback?: string) => fallback || 'Request failed.',
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<CvBuilderPage />
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('lists existing CVs from the variants API', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() },
|
||||
],
|
||||
} as any);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants');
|
||||
});
|
||||
|
||||
test('shows the empty state and creates a CV then navigates to the editor', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('No CVs yet')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /New CV/i })[0]);
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' })));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
|
||||
});
|
||||
Reference in New Issue
Block a user