feat: complete release readiness work #28

Open
cesnimda wants to merge 110 commits from release-readiness into main
2 changed files with 59 additions and 21 deletions
Showing only changes of commit 0dfaac18a1 - Show all commits
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import CvBuilderEditor from './views/CvBuilderEditor';
@@ -8,6 +8,8 @@ import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast';
import { api } from './api';
import { emptyCvVariantSettings } from './cvBuilder';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
// The Application Workspace CV section deep-links straight into the builder at
// /career/builder/:id. These tests cover that entry point: the variant loads, and a variant the
@@ -44,7 +46,11 @@ function renderAt(id: number) {
return render(
<I18nProvider>
<ToastProvider>
<RouterProvider router={router} />
<ConfirmProvider>
<PromptProvider>
<RouterProvider router={router} />
</PromptProvider>
</ConfirmProvider>
</ToastProvider>
</I18nProvider>,
);
@@ -124,20 +130,20 @@ test('failed autosave is visible and can be retried with the latest data', async
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false);
renderAt(3);
fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Unsaved headline' } });
fireEvent.click(screen.getByRole('button', { name: 'Back to CVs' }));
expect(confirm).toHaveBeenCalledWith('This CV has unsaved changes. Leave and discard them?');
const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' });
expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
confirm.mockRestore();
});
test('custom entries can be added, edited, reordered and deleted with confirmation', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
mockedApi.put.mockResolvedValue({ data: variant } as any);
const confirm = jest.spyOn(window, 'confirm');
renderAt(3);
await screen.findByLabelText('Headline override');
@@ -152,11 +158,16 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
expect(screen.getByLabelText('Entry 1')).toHaveValue('Second project');
expect(screen.getByLabelText('Entry 2')).toHaveValue('First project');
confirm.mockReturnValueOnce(false).mockReturnValueOnce(true);
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
let dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1);
dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete entry' }));
await waitFor(() => expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
expect(await screen.findByText('Saved')).toBeInTheDocument();
@@ -166,12 +177,15 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
}),
}));
confirm.mockReturnValueOnce(false).mockReturnValueOnce(true);
fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' }));
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByLabelText('Custom section title')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' }));
expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument();
confirm.mockRestore();
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete section' }));
await waitFor(() => expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument());
});
test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => {
+34 -10
View File
@@ -31,6 +31,7 @@ import {
cvBuilderApi, moveItem,
} from "../cvBuilder";
import { useAccountPlan } from "../accountPlan";
import { useDialogActions } from "../dialogs";
const FONTS = [
"'Segoe UI', Roboto, Arial, sans-serif",
@@ -49,6 +50,7 @@ export default function CvBuilderEditor() {
const variantId = Number(id);
const navigate = useNavigate();
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const [name, setName] = useState("");
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
@@ -76,6 +78,7 @@ export default function CvBuilderEditor() {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const contentHeight = useRef(A4_PAGE_PX);
const blockerPromptOpen = useRef(false);
useEffect(() => {
let alive = true;
@@ -186,10 +189,22 @@ export default function CvBuilderEditor() {
const blocker = useBlocker(hasUnsavedChanges);
useEffect(() => {
if (blocker.state !== "blocked") return;
if (window.confirm("This CV has unsaved changes. Leave and discard them?")) blocker.proceed();
else blocker.reset();
}, [blocker]);
if (blocker.state !== "blocked") {
blockerPromptOpen.current = false;
return;
}
if (blockerPromptOpen.current) return;
blockerPromptOpen.current = true;
void confirmAction("This CV has unsaved changes. Leave and discard them?", {
title: "Discard unsaved CV changes?",
confirmLabel: "Discard and leave",
destructive: true,
}).then((confirmed) => {
blockerPromptOpen.current = false;
if (confirmed) blocker.proceed();
else blocker.reset();
});
}, [blocker, confirmAction]);
useEffect(() => {
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -403,6 +418,7 @@ function ContentTab({ settings, update, outline }: {
update: (p: Partial<CvVariantSettings>) => void;
outline: CvOutline | null;
}) {
const { confirmAction } = useDialogActions();
// Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => {
const base = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
@@ -429,8 +445,12 @@ function ContentTab({ settings, update, outline }: {
};
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
const removeCustom = (section: CvCustomSectionSetting) => {
if (!window.confirm(`Delete the custom section "${section.title || "Untitled"}"?`)) return;
const removeCustom = async (section: CvCustomSectionSetting) => {
if (!(await confirmAction(`Delete the custom section "${section.title || "Untitled"}" and all of its entries?`, {
title: "Delete custom section",
confirmLabel: "Delete section",
destructive: true,
}))) return;
update({ customSections: settings.customSections.filter((c) => c.key !== section.key) });
};
const moveCustom = (index: number, delta: number) =>
@@ -440,9 +460,13 @@ function ContentTab({ settings, update, outline }: {
if (!section) return;
updateCustom(key, { items: section.items.map((item, itemIndex) => itemIndex === index ? value : item) });
};
const removeCustomItem = (section: CvCustomSectionSetting, index: number) => {
const removeCustomItem = async (section: CvCustomSectionSetting, index: number) => {
const value = section.items[index];
if (value.trim() && !window.confirm("Delete this custom section entry?")) return;
if (value.trim() && !(await confirmAction("Delete this custom section entry?", {
title: "Delete custom entry",
confirmLabel: "Delete entry",
destructive: true,
}))) return;
updateCustom(section.key, { items: section.items.filter((_, itemIndex) => itemIndex !== index) });
};
@@ -497,7 +521,7 @@ function ContentTab({ settings, update, outline }: {
<IconButton size="small" aria-label={c.hidden ? "Show custom section" : "Hide custom section"} onClick={() => updateCustom(c.key, { hidden: !c.hidden })}>
{c.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
<IconButton size="small" aria-label="Remove custom section" onClick={() => removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label="Remove custom section" onClick={() => void removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
{!c.hidden ? (
<Stack spacing={1} sx={{ mt: 1 }}>
@@ -510,7 +534,7 @@ function ContentTab({ settings, update, outline }: {
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
))}
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>