diff --git a/web-ui/src/__tests__/components/prd/PRDVersionHistoryModal.test.tsx b/web-ui/src/__tests__/components/prd/PRDVersionHistoryModal.test.tsx new file mode 100644 index 00000000..80f2584e --- /dev/null +++ b/web-ui/src/__tests__/components/prd/PRDVersionHistoryModal.test.tsx @@ -0,0 +1,295 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import useSWR from 'swr'; +import { PRDVersionHistoryModal } from '@/components/prd/PRDVersionHistoryModal'; +import { prdApi } from '@/lib/api'; +import type { PrdResponse, PrdDiffResponse } from '@/types'; + +// ResizeObserver is not available in jsdom +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +jest.mock('swr'); +jest.mock('sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); +// Radix ScrollArea Viewport hides children in jsdom — render children directly +jest.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, + ScrollBar: () => null, +})); + +jest.mock('@/lib/api', () => ({ + prdApi: { + getVersions: jest.fn(), + diff: jest.fn(), + createVersion: jest.fn(), + }, +})); + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockDiff = prdApi.diff as jest.MockedFunction; +const mockCreateVersion = prdApi.createVersion as jest.MockedFunction; + +const WORKSPACE = '/home/user/project'; + +const makeVersion = (v: number, summary: string | null = null): PrdResponse => ({ + id: `prd-${v}`, + workspace_id: 'ws-1', + title: 'My PRD', + content: `# Version ${v} content`, + metadata: {}, + created_at: `2026-01-0${v}T00:00:00Z`, + version: v, + parent_id: v > 1 ? `prd-${v - 1}` : null, + change_summary: summary, + chain_id: 'chain-1', +}); + +const fakeVersions: PrdResponse[] = [ + makeVersion(3, 'Added section 3'), + makeVersion(2, 'Updated intro'), + makeVersion(1, null), +]; + +const currentPrd = fakeVersions[0]; + +const defaultProps = { + open: true, + onOpenChange: jest.fn(), + prd: currentPrd, + workspacePath: WORKSPACE, + onVersionRestored: jest.fn(), +}; + +function setupSWR(versions = fakeVersions) { + mockUseSWR.mockReturnValue({ + data: versions, + error: undefined, + isLoading: false, + mutate: jest.fn(), + } as unknown as ReturnType); +} + +describe('PRDVersionHistoryModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupSWR(); + }); + + it('renders the dialog with version list', () => { + render(); + expect(screen.getByText('Version History')).toBeInTheDocument(); + expect(screen.getByText('Version 3')).toBeInTheDocument(); + expect(screen.getByText('Version 2')).toBeInTheDocument(); + expect(screen.getByText('Version 1')).toBeInTheDocument(); + }); + + it('shows change_summary when present', () => { + render(); + expect(screen.getByText('Added section 3')).toBeInTheDocument(); + expect(screen.getByText('Updated intro')).toBeInTheDocument(); + }); + + it('shows "No summary" for versions with null change_summary', () => { + render(); + expect(screen.getByText('No summary')).toBeInTheDocument(); + }); + + it('highlights the current version with a "Current" badge', () => { + render(); + expect(screen.getByText('Current')).toBeInTheDocument(); + }); + + it('shows loading state while fetching', () => { + mockUseSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true, + mutate: jest.fn(), + } as unknown as ReturnType); + + render(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('shows error state on fetch failure', () => { + mockUseSWR.mockReturnValue({ + data: undefined, + error: new Error('Network error'), + isLoading: false, + mutate: jest.fn(), + } as unknown as ReturnType); + + render(); + expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); + }); + + describe('View version', () => { + it('shows content preview when View button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); // click View on version 2 + + expect(screen.getByText(/Version 2 content/)).toBeInTheDocument(); + }); + + it('shows "Back to list" button in preview mode', async () => { + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); + + expect(screen.getByRole('button', { name: /back to list/i })).toBeInTheDocument(); + }); + + it('returns to version list when Back is clicked', async () => { + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); + await user.click(screen.getByRole('button', { name: /back to list/i })); + + expect(screen.getByText('Version History')).toBeInTheDocument(); + expect(screen.queryByText('# Version 2 content')).not.toBeInTheDocument(); + }); + }); + + describe('Compare with current', () => { + it('calls prdApi.diff and shows diff output', async () => { + const fakeDiff: PrdDiffResponse = { + version1: 2, + version2: 3, + diff: '@@ -1 +1 @@\n-# Version 2 content\n+# Version 3 content', + }; + mockDiff.mockResolvedValueOnce(fakeDiff); + + const user = userEvent.setup(); + render(); + + // versions ordered newest-first; version 3 is current (no View btn), so index 0 = version 2 + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); // version 2 + + const compareBtn = screen.getByRole('button', { name: /compare with current/i }); + await user.click(compareBtn); + + await waitFor(() => { + expect(mockDiff).toHaveBeenCalledWith( + currentPrd.id, + WORKSPACE, + 2, + 3 + ); + }); + + await waitFor(() => { + expect(screen.getByText(/@@ -1 \+1 @@/)).toBeInTheDocument(); + }); + }); + + it('shows error message and re-enables Compare button on diff failure', async () => { + mockDiff.mockRejectedValueOnce(new Error('Network error')); + + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); // version 2 + + const compareBtn = screen.getByRole('button', { name: /compare with current/i }); + await user.click(compareBtn); + + await waitFor(() => { + expect(mockDiff).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText(/failed to load diff/i)).toBeInTheDocument(); + }); + + // Compare button should be re-enabled so the user can retry + expect(screen.getByRole('button', { name: /compare with current/i })).not.toBeDisabled(); + }); + }); + + describe('Restore version', () => { + it('shows confirmation UI when Restore is clicked', async () => { + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); // version 2 + + const restoreBtn = screen.getByRole('button', { name: /restore this version/i }); + await user.click(restoreBtn); + + expect(screen.getByText(/restore version 2/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm restore/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('calls createVersion with restored content on confirm', async () => { + const restoredPrd = makeVersion(4, 'Restored from version 2'); + mockCreateVersion.mockResolvedValueOnce(restoredPrd); + + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); // version 2 + await user.click(screen.getByRole('button', { name: /restore this version/i })); + await user.click(screen.getByRole('button', { name: /confirm restore/i })); + + await waitFor(() => { + expect(mockCreateVersion).toHaveBeenCalledWith( + currentPrd.id, + WORKSPACE, + '# Version 2 content', + 'Restored from version 2' + ); + }); + + await waitFor(() => { + expect(defaultProps.onVersionRestored).toHaveBeenCalledWith(restoredPrd); + }); + }); + + it('cancels restore without calling API', async () => { + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + await user.click(viewButtons[0]); + await user.click(screen.getByRole('button', { name: /restore this version/i })); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(mockCreateVersion).not.toHaveBeenCalled(); + // Should return to preview without confirmation UI + expect(screen.queryByRole('button', { name: /confirm restore/i })).not.toBeInTheDocument(); + }); + + it('does not show Restore button for the current version', () => { + render(); + + // Version 3 is current — its View button should not be visible (or Restore should be absent) + // The current version row should not have a "View" button at all + const viewButtons = screen.getAllByRole('button', { name: /^view$/i }); + // Only versions 1 and 2 should have View buttons (not version 3 which is current) + expect(viewButtons).toHaveLength(2); + }); + }); + + it('does not render version list when closed', () => { + render(); + expect(screen.queryByText('Version History')).not.toBeInTheDocument(); + }); +}); diff --git a/web-ui/src/app/prd/page.tsx b/web-ui/src/app/prd/page.tsx index e162cf4c..9c8a7798 100644 --- a/web-ui/src/app/prd/page.tsx +++ b/web-ui/src/app/prd/page.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import useSWR from 'swr'; import { PRDView } from '@/components/prd'; import { UploadPRDModal } from '@/components/prd/UploadPRDModal'; +import { PRDVersionHistoryModal } from '@/components/prd/PRDVersionHistoryModal'; import { prdApi, tasksApi, discoveryApi } from '@/lib/api'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; import type { @@ -21,6 +22,7 @@ export default function PrdPage() { const [discoveryOpen, setDiscoveryOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isGeneratingTasks, setIsGeneratingTasks] = useState(false); + const [versionHistoryOpen, setVersionHistoryOpen] = useState(false); useEffect(() => { setWorkspacePath(getSelectedWorkspacePath()); @@ -127,6 +129,11 @@ export default function PrdPage() { setDiscoveryOpen(false); }; + const handleVersionRestored = (newPrd: PrdResponse) => { + mutatePrd(newPrd, false); + setVersionHistoryOpen(false); + }; + const handleGenerateTasks = async () => { if (!workspacePath || !prd) return; setIsGeneratingTasks(true); @@ -167,6 +174,7 @@ export default function PrdPage() { onGenerateTasks={handleGenerateTasks} onSavePrd={handleSavePrd} onPrdGenerated={handlePrdGenerated} + onViewHistory={() => setVersionHistoryOpen(true)} /> + + {hasPrd && prd && ( + + )} ); diff --git a/web-ui/src/components/prd/PRDHeader.tsx b/web-ui/src/components/prd/PRDHeader.tsx index 4d4fb13f..16530fdf 100644 --- a/web-ui/src/components/prd/PRDHeader.tsx +++ b/web-ui/src/components/prd/PRDHeader.tsx @@ -6,6 +6,7 @@ import { MessageSearch01Icon, TaskEdit01Icon, Loading03Icon, + Time01Icon, } from '@hugeicons/react'; import { Button } from '@/components/ui/button'; import type { PrdResponse } from '@/types'; @@ -16,6 +17,7 @@ interface PRDHeaderProps { onUploadPrd: () => void; onStartDiscovery: () => void; onGenerateTasks: () => void; + onViewHistory?: () => void; } export function PRDHeader({ @@ -24,6 +26,7 @@ export function PRDHeader({ onUploadPrd, onStartDiscovery, onGenerateTasks, + onViewHistory, }: PRDHeaderProps) { return (
@@ -43,6 +46,12 @@ export function PRDHeader({
+ {prd && onViewHistory && ( + + )} + )} +
+ ); + })} + + + ); +} + +// ── Version Preview ─────────────────────────────────────────────────────────── + +interface VersionPreviewProps { + preview: PreviewState; + onBack: () => void; + onCompare: () => void; + onStartRestore: () => void; + onCancelRestore: () => void; + onConfirmRestore: () => void; +} + +function VersionPreview({ + preview, + onBack, + onCompare, + onStartRestore, + onCancelRestore, + onConfirmRestore, +}: VersionPreviewProps) { + const { version, diff, diffLoading, diffError, confirmingRestore, restoring } = preview; + + return ( +
+
+ +
+ + {!confirmingRestore && ( + + )} +
+
+ + {confirmingRestore ? ( +
+

+ Restore version {version.version}? +

+

+ This will create a new version with this content. Your current version + will not be deleted. +

+
+ + +
+
+ ) : diffError ? ( +
+ Failed to load diff. Click “Compare with current” to try again. +
+ ) : diff ? ( + +
+            {diff.diff}
+          
+
+ ) : ( + +
+            {version.content}
+          
+
+ )} +
+ ); +} diff --git a/web-ui/src/components/prd/PRDView.tsx b/web-ui/src/components/prd/PRDView.tsx index f4df3f14..ff03168d 100644 --- a/web-ui/src/components/prd/PRDView.tsx +++ b/web-ui/src/components/prd/PRDView.tsx @@ -23,6 +23,7 @@ interface PRDViewProps { onGenerateTasks: () => void; onSavePrd: (content: string, changeSummary: string) => Promise; onPrdGenerated: (prd: PrdResponse) => void; + onViewHistory?: () => void; } export function PRDView({ @@ -39,6 +40,7 @@ export function PRDView({ onGenerateTasks, onSavePrd, onPrdGenerated, + onViewHistory, }: PRDViewProps) { if (isLoading) { return ( @@ -94,6 +96,7 @@ export function PRDView({ onUploadPrd={onUploadPrd} onStartDiscovery={onStartDiscovery} onGenerateTasks={onGenerateTasks} + onViewHistory={onViewHistory} /> {taskCounts && ( diff --git a/web-ui/src/components/prd/index.ts b/web-ui/src/components/prd/index.ts index 75e72048..95e4c488 100644 --- a/web-ui/src/components/prd/index.ts +++ b/web-ui/src/components/prd/index.ts @@ -6,3 +6,4 @@ export { DiscoveryPanel } from './DiscoveryPanel'; export { DiscoveryTranscript } from './DiscoveryTranscript'; export { DiscoveryInput } from './DiscoveryInput'; export { AssociatedTasksSummary } from './AssociatedTasksSummary'; +export { PRDVersionHistoryModal } from './PRDVersionHistoryModal';