Skip to content

Commit cfc1344

Browse files
authored
feat(web-ui): PRD version history panel with diff and restore (#482)
## Summary - Add `PRDVersionHistoryModal` — Dialog listing all saved versions (newest first) with timestamps and change summaries - Version read-only preview, unified diff comparison against current, and non-destructive restore via `prdApi.createVersion()` - Add "History" button to `PRDHeader` (only shown when a PRD exists) - Wire state through `PRDView` → `page.tsx` (`versionHistoryOpen` + `onViewHistory` callback) - 16 unit tests covering all UI states, interactive flows (view/compare/restore), and error handling ## Validation - Review feedback: All addressed (2 rounds — unused prop removed, error feedback added to diff/restore, re-compare allowed) - Demo: All 5 acceptance criteria verified via browser + test suite - Tests: 16/16 passing - CI: All checks green - Linting: 0 errors Closes #482
1 parent 511f505 commit cfc1344

6 files changed

Lines changed: 651 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import React from 'react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import useSWR from 'swr';
5+
import { PRDVersionHistoryModal } from '@/components/prd/PRDVersionHistoryModal';
6+
import { prdApi } from '@/lib/api';
7+
import type { PrdResponse, PrdDiffResponse } from '@/types';
8+
9+
// ResizeObserver is not available in jsdom
10+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
11+
observe: jest.fn(),
12+
unobserve: jest.fn(),
13+
disconnect: jest.fn(),
14+
}));
15+
16+
jest.mock('swr');
17+
jest.mock('sonner', () => ({
18+
toast: { success: jest.fn(), error: jest.fn() },
19+
}));
20+
// Radix ScrollArea Viewport hides children in jsdom — render children directly
21+
jest.mock('@/components/ui/scroll-area', () => ({
22+
ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
23+
ScrollBar: () => null,
24+
}));
25+
26+
jest.mock('@/lib/api', () => ({
27+
prdApi: {
28+
getVersions: jest.fn(),
29+
diff: jest.fn(),
30+
createVersion: jest.fn(),
31+
},
32+
}));
33+
34+
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
35+
const mockDiff = prdApi.diff as jest.MockedFunction<typeof prdApi.diff>;
36+
const mockCreateVersion = prdApi.createVersion as jest.MockedFunction<typeof prdApi.createVersion>;
37+
38+
const WORKSPACE = '/home/user/project';
39+
40+
const makeVersion = (v: number, summary: string | null = null): PrdResponse => ({
41+
id: `prd-${v}`,
42+
workspace_id: 'ws-1',
43+
title: 'My PRD',
44+
content: `# Version ${v} content`,
45+
metadata: {},
46+
created_at: `2026-01-0${v}T00:00:00Z`,
47+
version: v,
48+
parent_id: v > 1 ? `prd-${v - 1}` : null,
49+
change_summary: summary,
50+
chain_id: 'chain-1',
51+
});
52+
53+
const fakeVersions: PrdResponse[] = [
54+
makeVersion(3, 'Added section 3'),
55+
makeVersion(2, 'Updated intro'),
56+
makeVersion(1, null),
57+
];
58+
59+
const currentPrd = fakeVersions[0];
60+
61+
const defaultProps = {
62+
open: true,
63+
onOpenChange: jest.fn(),
64+
prd: currentPrd,
65+
workspacePath: WORKSPACE,
66+
onVersionRestored: jest.fn(),
67+
};
68+
69+
function setupSWR(versions = fakeVersions) {
70+
mockUseSWR.mockReturnValue({
71+
data: versions,
72+
error: undefined,
73+
isLoading: false,
74+
mutate: jest.fn(),
75+
} as unknown as ReturnType<typeof useSWR>);
76+
}
77+
78+
describe('PRDVersionHistoryModal', () => {
79+
beforeEach(() => {
80+
jest.clearAllMocks();
81+
setupSWR();
82+
});
83+
84+
it('renders the dialog with version list', () => {
85+
render(<PRDVersionHistoryModal {...defaultProps} />);
86+
expect(screen.getByText('Version History')).toBeInTheDocument();
87+
expect(screen.getByText('Version 3')).toBeInTheDocument();
88+
expect(screen.getByText('Version 2')).toBeInTheDocument();
89+
expect(screen.getByText('Version 1')).toBeInTheDocument();
90+
});
91+
92+
it('shows change_summary when present', () => {
93+
render(<PRDVersionHistoryModal {...defaultProps} />);
94+
expect(screen.getByText('Added section 3')).toBeInTheDocument();
95+
expect(screen.getByText('Updated intro')).toBeInTheDocument();
96+
});
97+
98+
it('shows "No summary" for versions with null change_summary', () => {
99+
render(<PRDVersionHistoryModal {...defaultProps} />);
100+
expect(screen.getByText('No summary')).toBeInTheDocument();
101+
});
102+
103+
it('highlights the current version with a "Current" badge', () => {
104+
render(<PRDVersionHistoryModal {...defaultProps} />);
105+
expect(screen.getByText('Current')).toBeInTheDocument();
106+
});
107+
108+
it('shows loading state while fetching', () => {
109+
mockUseSWR.mockReturnValue({
110+
data: undefined,
111+
error: undefined,
112+
isLoading: true,
113+
mutate: jest.fn(),
114+
} as unknown as ReturnType<typeof useSWR>);
115+
116+
render(<PRDVersionHistoryModal {...defaultProps} />);
117+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
118+
});
119+
120+
it('shows error state on fetch failure', () => {
121+
mockUseSWR.mockReturnValue({
122+
data: undefined,
123+
error: new Error('Network error'),
124+
isLoading: false,
125+
mutate: jest.fn(),
126+
} as unknown as ReturnType<typeof useSWR>);
127+
128+
render(<PRDVersionHistoryModal {...defaultProps} />);
129+
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
130+
});
131+
132+
describe('View version', () => {
133+
it('shows content preview when View button is clicked', async () => {
134+
const user = userEvent.setup();
135+
render(<PRDVersionHistoryModal {...defaultProps} />);
136+
137+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
138+
await user.click(viewButtons[0]); // click View on version 2
139+
140+
expect(screen.getByText(/Version 2 content/)).toBeInTheDocument();
141+
});
142+
143+
it('shows "Back to list" button in preview mode', async () => {
144+
const user = userEvent.setup();
145+
render(<PRDVersionHistoryModal {...defaultProps} />);
146+
147+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
148+
await user.click(viewButtons[0]);
149+
150+
expect(screen.getByRole('button', { name: /back to list/i })).toBeInTheDocument();
151+
});
152+
153+
it('returns to version list when Back is clicked', async () => {
154+
const user = userEvent.setup();
155+
render(<PRDVersionHistoryModal {...defaultProps} />);
156+
157+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
158+
await user.click(viewButtons[0]);
159+
await user.click(screen.getByRole('button', { name: /back to list/i }));
160+
161+
expect(screen.getByText('Version History')).toBeInTheDocument();
162+
expect(screen.queryByText('# Version 2 content')).not.toBeInTheDocument();
163+
});
164+
});
165+
166+
describe('Compare with current', () => {
167+
it('calls prdApi.diff and shows diff output', async () => {
168+
const fakeDiff: PrdDiffResponse = {
169+
version1: 2,
170+
version2: 3,
171+
diff: '@@ -1 +1 @@\n-# Version 2 content\n+# Version 3 content',
172+
};
173+
mockDiff.mockResolvedValueOnce(fakeDiff);
174+
175+
const user = userEvent.setup();
176+
render(<PRDVersionHistoryModal {...defaultProps} />);
177+
178+
// versions ordered newest-first; version 3 is current (no View btn), so index 0 = version 2
179+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
180+
await user.click(viewButtons[0]); // version 2
181+
182+
const compareBtn = screen.getByRole('button', { name: /compare with current/i });
183+
await user.click(compareBtn);
184+
185+
await waitFor(() => {
186+
expect(mockDiff).toHaveBeenCalledWith(
187+
currentPrd.id,
188+
WORKSPACE,
189+
2,
190+
3
191+
);
192+
});
193+
194+
await waitFor(() => {
195+
expect(screen.getByText(/@@ -1 \+1 @@/)).toBeInTheDocument();
196+
});
197+
});
198+
199+
it('shows error message and re-enables Compare button on diff failure', async () => {
200+
mockDiff.mockRejectedValueOnce(new Error('Network error'));
201+
202+
const user = userEvent.setup();
203+
render(<PRDVersionHistoryModal {...defaultProps} />);
204+
205+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
206+
await user.click(viewButtons[0]); // version 2
207+
208+
const compareBtn = screen.getByRole('button', { name: /compare with current/i });
209+
await user.click(compareBtn);
210+
211+
await waitFor(() => {
212+
expect(mockDiff).toHaveBeenCalled();
213+
});
214+
215+
await waitFor(() => {
216+
expect(screen.getByText(/failed to load diff/i)).toBeInTheDocument();
217+
});
218+
219+
// Compare button should be re-enabled so the user can retry
220+
expect(screen.getByRole('button', { name: /compare with current/i })).not.toBeDisabled();
221+
});
222+
});
223+
224+
describe('Restore version', () => {
225+
it('shows confirmation UI when Restore is clicked', async () => {
226+
const user = userEvent.setup();
227+
render(<PRDVersionHistoryModal {...defaultProps} />);
228+
229+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
230+
await user.click(viewButtons[0]); // version 2
231+
232+
const restoreBtn = screen.getByRole('button', { name: /restore this version/i });
233+
await user.click(restoreBtn);
234+
235+
expect(screen.getByText(/restore version 2/i)).toBeInTheDocument();
236+
expect(screen.getByRole('button', { name: /confirm restore/i })).toBeInTheDocument();
237+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
238+
});
239+
240+
it('calls createVersion with restored content on confirm', async () => {
241+
const restoredPrd = makeVersion(4, 'Restored from version 2');
242+
mockCreateVersion.mockResolvedValueOnce(restoredPrd);
243+
244+
const user = userEvent.setup();
245+
render(<PRDVersionHistoryModal {...defaultProps} />);
246+
247+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
248+
await user.click(viewButtons[0]); // version 2
249+
await user.click(screen.getByRole('button', { name: /restore this version/i }));
250+
await user.click(screen.getByRole('button', { name: /confirm restore/i }));
251+
252+
await waitFor(() => {
253+
expect(mockCreateVersion).toHaveBeenCalledWith(
254+
currentPrd.id,
255+
WORKSPACE,
256+
'# Version 2 content',
257+
'Restored from version 2'
258+
);
259+
});
260+
261+
await waitFor(() => {
262+
expect(defaultProps.onVersionRestored).toHaveBeenCalledWith(restoredPrd);
263+
});
264+
});
265+
266+
it('cancels restore without calling API', async () => {
267+
const user = userEvent.setup();
268+
render(<PRDVersionHistoryModal {...defaultProps} />);
269+
270+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
271+
await user.click(viewButtons[0]);
272+
await user.click(screen.getByRole('button', { name: /restore this version/i }));
273+
await user.click(screen.getByRole('button', { name: /cancel/i }));
274+
275+
expect(mockCreateVersion).not.toHaveBeenCalled();
276+
// Should return to preview without confirmation UI
277+
expect(screen.queryByRole('button', { name: /confirm restore/i })).not.toBeInTheDocument();
278+
});
279+
280+
it('does not show Restore button for the current version', () => {
281+
render(<PRDVersionHistoryModal {...defaultProps} />);
282+
283+
// Version 3 is current — its View button should not be visible (or Restore should be absent)
284+
// The current version row should not have a "View" button at all
285+
const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
286+
// Only versions 1 and 2 should have View buttons (not version 3 which is current)
287+
expect(viewButtons).toHaveLength(2);
288+
});
289+
});
290+
291+
it('does not render version list when closed', () => {
292+
render(<PRDVersionHistoryModal {...defaultProps} open={false} />);
293+
expect(screen.queryByText('Version History')).not.toBeInTheDocument();
294+
});
295+
});

web-ui/src/app/prd/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { toast } from 'sonner';
66
import useSWR from 'swr';
77
import { PRDView } from '@/components/prd';
88
import { UploadPRDModal } from '@/components/prd/UploadPRDModal';
9+
import { PRDVersionHistoryModal } from '@/components/prd/PRDVersionHistoryModal';
910
import { prdApi, tasksApi, discoveryApi } from '@/lib/api';
1011
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
1112
import type {
@@ -21,6 +22,7 @@ export default function PrdPage() {
2122
const [discoveryOpen, setDiscoveryOpen] = useState(false);
2223
const [isSaving, setIsSaving] = useState(false);
2324
const [isGeneratingTasks, setIsGeneratingTasks] = useState(false);
25+
const [versionHistoryOpen, setVersionHistoryOpen] = useState(false);
2426

2527
useEffect(() => {
2628
setWorkspacePath(getSelectedWorkspacePath());
@@ -127,6 +129,11 @@ export default function PrdPage() {
127129
setDiscoveryOpen(false);
128130
};
129131

132+
const handleVersionRestored = (newPrd: PrdResponse) => {
133+
mutatePrd(newPrd, false);
134+
setVersionHistoryOpen(false);
135+
};
136+
130137
const handleGenerateTasks = async () => {
131138
if (!workspacePath || !prd) return;
132139
setIsGeneratingTasks(true);
@@ -167,6 +174,7 @@ export default function PrdPage() {
167174
onGenerateTasks={handleGenerateTasks}
168175
onSavePrd={handleSavePrd}
169176
onPrdGenerated={handlePrdGenerated}
177+
onViewHistory={() => setVersionHistoryOpen(true)}
170178
/>
171179

172180
<UploadPRDModal
@@ -175,6 +183,16 @@ export default function PrdPage() {
175183
workspacePath={workspacePath}
176184
onSuccess={handleUploadSuccess}
177185
/>
186+
187+
{hasPrd && prd && (
188+
<PRDVersionHistoryModal
189+
open={versionHistoryOpen}
190+
onOpenChange={setVersionHistoryOpen}
191+
prd={prd}
192+
workspacePath={workspacePath}
193+
onVersionRestored={handleVersionRestored}
194+
/>
195+
)}
178196
</div>
179197
</main>
180198
);

web-ui/src/components/prd/PRDHeader.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
MessageSearch01Icon,
77
TaskEdit01Icon,
88
Loading03Icon,
9+
Time01Icon,
910
} from '@hugeicons/react';
1011
import { Button } from '@/components/ui/button';
1112
import type { PrdResponse } from '@/types';
@@ -16,6 +17,7 @@ interface PRDHeaderProps {
1617
onUploadPrd: () => void;
1718
onStartDiscovery: () => void;
1819
onGenerateTasks: () => void;
20+
onViewHistory?: () => void;
1921
}
2022

2123
export function PRDHeader({
@@ -24,6 +26,7 @@ export function PRDHeader({
2426
onUploadPrd,
2527
onStartDiscovery,
2628
onGenerateTasks,
29+
onViewHistory,
2730
}: PRDHeaderProps) {
2831
return (
2932
<header className="flex items-center justify-between">
@@ -43,6 +46,12 @@ export function PRDHeader({
4346
</div>
4447

4548
<div className="flex gap-2">
49+
{prd && onViewHistory && (
50+
<Button variant="outline" size="sm" onClick={onViewHistory}>
51+
<Time01Icon className="mr-1.5 h-4 w-4" />
52+
History
53+
</Button>
54+
)}
4655
<Button variant="outline" size="sm" onClick={onUploadPrd}>
4756
<Upload04Icon className="mr-1.5 h-4 w-4" />
4857
{prd ? 'Upload New' : 'Upload PRD'}

0 commit comments

Comments
 (0)