Skip to content

Commit 468f757

Browse files
committed
feat: implement cookbook pattern detail view
- Detail page with breadcrumbs, pattern info, type badge, complexity indicator - Tabbed interface: Manifest (YAML viewer), Starlark (read-only editor), Composition - ManifestViewer component with CodeMirror and copy-to-clipboard - usePatternFiles hook for lazy file content fetching - Composition section with linked badges for dependencies - UI component type shows placeholder for future preview - Tests for detail page, manifest viewer, and pattern files hook
1 parent 2a0829f commit 468f757

7 files changed

Lines changed: 588 additions & 3 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { ManifestViewer } from './manifest-viewer'
4+
5+
vi.mock('codemirror', () => ({ basicSetup: [] }))
6+
vi.mock('@codemirror/view', () => ({
7+
EditorView: class MockEditorView {
8+
static editable = { of: vi.fn(() => ({})) }
9+
dom: HTMLElement
10+
state: { doc: { toString: () => string } }
11+
dispatch = vi.fn()
12+
constructor(config: { state?: unknown; parent?: HTMLElement }) {
13+
this.dom = document.createElement('div')
14+
this.dom.className = 'cm-editor'
15+
this.dom.textContent = 'yaml content'
16+
this.state = { doc: { toString: () => 'yaml content' } }
17+
if (config.parent) config.parent.appendChild(this.dom)
18+
}
19+
destroy() {}
20+
},
21+
}))
22+
vi.mock('@codemirror/state', () => ({
23+
EditorState: { create: vi.fn(() => ({})), readOnly: { of: vi.fn(() => ({})) } },
24+
}))
25+
26+
describe('ManifestViewer', () => {
27+
it('renders the viewer container', () => {
28+
render(<ManifestViewer content="name: test" />)
29+
expect(screen.getByTestId('manifest-viewer')).toBeInTheDocument()
30+
})
31+
32+
it('renders the copy button', () => {
33+
render(<ManifestViewer content="name: test" />)
34+
expect(screen.getByRole('button', { name: /copy manifest/i })).toBeInTheDocument()
35+
})
36+
37+
it('copies content to clipboard on button click', async () => {
38+
const writeText = vi.fn().mockResolvedValue(undefined)
39+
Object.assign(navigator, { clipboard: { writeText } })
40+
41+
render(<ManifestViewer content="name: test" />)
42+
fireEvent.click(screen.getByRole('button', { name: /copy manifest/i }))
43+
44+
expect(writeText).toHaveBeenCalledWith('name: test')
45+
})
46+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { EditorView } from '@codemirror/view'
3+
import { EditorState } from '@codemirror/state'
4+
import { basicSetup } from 'codemirror'
5+
import { Copy, Check } from 'lucide-react'
6+
import { Button } from '@/components/ui/button'
7+
import { cn } from '@/lib/utils'
8+
9+
interface ManifestViewerProps {
10+
content: string
11+
className?: string
12+
}
13+
14+
export function ManifestViewer({ content, className }: ManifestViewerProps) {
15+
const editorRef = useRef<HTMLDivElement>(null)
16+
const viewRef = useRef<EditorView | null>(null)
17+
const [copied, setCopied] = useState(false)
18+
19+
useEffect(() => {
20+
if (!editorRef.current) return
21+
22+
const view = new EditorView({
23+
state: EditorState.create({
24+
doc: content,
25+
extensions: [
26+
basicSetup,
27+
EditorView.editable.of(false),
28+
EditorState.readOnly.of(true),
29+
],
30+
}),
31+
parent: editorRef.current,
32+
})
33+
34+
viewRef.current = view
35+
36+
return () => {
37+
view.destroy()
38+
viewRef.current = null
39+
}
40+
}, [content])
41+
42+
const handleCopy = useCallback(async () => {
43+
await navigator.clipboard.writeText(content)
44+
setCopied(true)
45+
setTimeout(() => setCopied(false), 2000)
46+
}, [content])
47+
48+
return (
49+
<div data-testid="manifest-viewer" className={cn('relative', className)}>
50+
<Button
51+
variant="ghost"
52+
size="sm"
53+
className="absolute right-2 top-2 z-10 h-7 w-7 p-0"
54+
onClick={handleCopy}
55+
aria-label="Copy manifest"
56+
>
57+
{copied ? (
58+
<Check className="h-3.5 w-3.5 text-green-600" />
59+
) : (
60+
<Copy className="h-3.5 w-3.5" />
61+
)}
62+
</Button>
63+
<div
64+
ref={editorRef}
65+
className="min-h-[200px] rounded border border-input bg-background text-sm"
66+
/>
67+
</div>
68+
)
69+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { renderHook, waitFor } from '@testing-library/react'
3+
import { usePatternFiles } from './use-pattern-files'
4+
5+
describe('usePatternFiles', () => {
6+
beforeEach(() => {
7+
vi.restoreAllMocks()
8+
})
9+
10+
it('returns null content when no pattern name provided', () => {
11+
const { result } = renderHook(() => usePatternFiles(undefined))
12+
expect(result.current.starlarkContent).toBeNull()
13+
expect(result.current.manifestContent).toBeNull()
14+
expect(result.current.isLoading).toBe(false)
15+
})
16+
17+
it('fetches starlark and manifest files', async () => {
18+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
19+
const path = typeof url === 'string' ? url : url.toString()
20+
if (path.includes('saga.star')) {
21+
return new Response('def execute():\n pass', { status: 200 })
22+
}
23+
if (path.includes('manifest.yaml')) {
24+
return new Response('name: test\ntype: registry:pattern', { status: 200 })
25+
}
26+
return new Response('', { status: 404 })
27+
})
28+
29+
const { result } = renderHook(() => usePatternFiles('test-pattern'))
30+
31+
await waitFor(() => expect(result.current.isLoading).toBe(false))
32+
33+
expect(result.current.starlarkContent).toBe('def execute():\n pass')
34+
expect(result.current.manifestContent).toBe('name: test\ntype: registry:pattern')
35+
})
36+
37+
it('returns null for files that return 404', async () => {
38+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
39+
40+
const { result } = renderHook(() => usePatternFiles('missing-pattern'))
41+
42+
await waitFor(() => expect(result.current.isLoading).toBe(false))
43+
44+
expect(result.current.starlarkContent).toBeNull()
45+
expect(result.current.manifestContent).toBeNull()
46+
})
47+
48+
it('returns null for fetch errors', async () => {
49+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
50+
51+
const { result } = renderHook(() => usePatternFiles('broken-pattern'))
52+
53+
await waitFor(() => expect(result.current.isLoading).toBe(false))
54+
55+
expect(result.current.starlarkContent).toBeNull()
56+
expect(result.current.manifestContent).toBeNull()
57+
})
58+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useEffect, useState } from 'react'
2+
3+
interface PatternFilesResult {
4+
starlarkContent: string | null
5+
manifestContent: string | null
6+
isLoading: boolean
7+
}
8+
9+
export function usePatternFiles(patternName: string | undefined): PatternFilesResult {
10+
const [starlarkContent, setStarlarkContent] = useState<string | null>(null)
11+
const [manifestContent, setManifestContent] = useState<string | null>(null)
12+
const [isLoading, setIsLoading] = useState(false)
13+
14+
useEffect(() => {
15+
if (!patternName) return
16+
17+
let cancelled = false
18+
setIsLoading(true)
19+
setStarlarkContent(null)
20+
setManifestContent(null)
21+
22+
const fetchFile = async (path: string): Promise<string | null> => {
23+
try {
24+
const res = await fetch(path)
25+
if (!res.ok) return null
26+
return await res.text()
27+
} catch {
28+
return null
29+
}
30+
}
31+
32+
Promise.all([
33+
fetchFile(`/cookbook/patterns/${patternName}/saga.star`),
34+
fetchFile(`/cookbook/patterns/${patternName}/manifest.yaml`),
35+
]).then(([star, yaml]) => {
36+
if (cancelled) return
37+
setStarlarkContent(star)
38+
setManifestContent(yaml)
39+
setIsLoading(false)
40+
})
41+
42+
return () => {
43+
cancelled = true
44+
}
45+
}, [patternName])
46+
47+
return { starlarkContent, manifestContent, isLoading }
48+
}

frontend/src/features/cookbook/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export { CookbookPage } from './pages/index'
22
export { CookbookDetailPage } from './pages/detail'
33
export type { CookbookItem, CookbookFile, CookbookRegistry, PatternMeta, ComponentMeta } from './hooks/use-cookbook'
44
export { useCookbook } from './hooks/use-cookbook'
5+
export { usePatternFiles } from './hooks/use-pattern-files'
6+
export { ManifestViewer } from './components/manifest-viewer'
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import { MemoryRouter, Route, Routes } from 'react-router-dom'
4+
import { CookbookDetailPage } from './detail'
5+
import type { CookbookItem, PatternMeta } from '../hooks/use-cookbook'
6+
7+
// Mock CodeMirror (jsdom doesn't support it)
8+
vi.mock('codemirror', () => ({ basicSetup: [] }))
9+
vi.mock('@codemirror/view', () => ({
10+
EditorView: class MockEditorView {
11+
static editable = { of: vi.fn(() => ({})) }
12+
static updateListener = { of: vi.fn(() => ({})) }
13+
dom: HTMLElement
14+
state: { doc: { toString: () => string } }
15+
dispatch = vi.fn()
16+
constructor(config: { doc?: string; extensions?: unknown[]; parent?: HTMLElement }) {
17+
this.dom = document.createElement('div')
18+
this.dom.className = 'cm-editor'
19+
this.state = { doc: { toString: () => config.doc ?? '' } }
20+
if (config.parent) config.parent.appendChild(this.dom)
21+
}
22+
destroy() {}
23+
},
24+
}))
25+
vi.mock('@codemirror/state', () => ({
26+
Compartment: class { of = vi.fn(() => ({})); reconfigure = vi.fn(() => ({})) },
27+
EditorState: { create: vi.fn(() => ({})), readOnly: { of: vi.fn(() => ({})) } },
28+
Transaction: { userEvent: 'user-event' },
29+
}))
30+
vi.mock('@codemirror/lang-python', () => ({ python: vi.fn(() => ({})) }))
31+
vi.mock('@codemirror/lint', () => ({
32+
linter: vi.fn(() => ({})),
33+
lintGutter: vi.fn(() => ({})),
34+
}))
35+
36+
const mockUseCookbook = vi.fn<() => { items: CookbookItem[]; isLoading: boolean }>()
37+
vi.mock('../hooks/use-cookbook', () => ({
38+
useCookbook: () => mockUseCookbook(),
39+
}))
40+
41+
const mockUsePatternFiles = vi.fn<() => { starlarkContent: string | null; manifestContent: string | null; isLoading: boolean }>()
42+
vi.mock('../hooks/use-pattern-files', () => ({
43+
usePatternFiles: () => mockUsePatternFiles(),
44+
}))
45+
46+
function renderDetail(name: string) {
47+
return render(
48+
<MemoryRouter initialEntries={[`/cookbook/${name}`]}>
49+
<Routes>
50+
<Route path="/cookbook/:name" element={<CookbookDetailPage />} />
51+
</Routes>
52+
</MemoryRouter>,
53+
)
54+
}
55+
56+
const patternItem: CookbookItem = {
57+
name: 'fiat-current-account',
58+
type: 'registry:pattern',
59+
title: 'Fiat Current Account',
60+
description: 'Standard fiat current account pattern for retail banking.',
61+
categories: ['banking', 'retail'],
62+
meta: {
63+
complexity: 3,
64+
design_pattern: 'Double-Entry Ledger',
65+
industries: ['banking'],
66+
composes_with: ['overdraft-facility'],
67+
extends: [],
68+
conflicts_with: ['crypto-wallet'],
69+
provides: { instruments: ['GBP'], sagas: ['deposit'] },
70+
requires: { instruments: ['GBP'] },
71+
} satisfies PatternMeta,
72+
}
73+
74+
const uiItem: CookbookItem = {
75+
name: 'transaction-table',
76+
type: 'registry:ui',
77+
title: 'Transaction Table',
78+
description: 'Reusable transaction data table.',
79+
}
80+
81+
describe('CookbookDetailPage', () => {
82+
beforeEach(() => {
83+
vi.clearAllMocks()
84+
mockUseCookbook.mockReturnValue({ items: [patternItem, uiItem], isLoading: false })
85+
mockUsePatternFiles.mockReturnValue({ starlarkContent: null, manifestContent: null, isLoading: false })
86+
})
87+
88+
it('shows loading skeleton while catalogue is loading', () => {
89+
mockUseCookbook.mockReturnValue({ items: [], isLoading: true })
90+
renderDetail('fiat-current-account')
91+
expect(screen.getByTestId('detail-skeleton')).toBeInTheDocument()
92+
})
93+
94+
it('shows not-found message for unknown pattern', () => {
95+
renderDetail('nonexistent')
96+
expect(screen.getByText(/not found/i)).toBeInTheDocument()
97+
})
98+
99+
it('renders pattern title and description', () => {
100+
renderDetail('fiat-current-account')
101+
expect(screen.getByRole('heading', { name: 'Fiat Current Account' })).toBeInTheDocument()
102+
expect(screen.getByText(/Standard fiat current account/)).toBeInTheDocument()
103+
})
104+
105+
it('renders type badge for pattern', () => {
106+
renderDetail('fiat-current-account')
107+
expect(screen.getByText('Pattern')).toBeInTheDocument()
108+
})
109+
110+
it('renders complexity indicator', () => {
111+
renderDetail('fiat-current-account')
112+
expect(screen.getByText(/Complexity: 3/)).toBeInTheDocument()
113+
})
114+
115+
it('renders categories as badges', () => {
116+
renderDetail('fiat-current-account')
117+
expect(screen.getByText('retail')).toBeInTheDocument()
118+
// 'banking' appears in both categories and industries
119+
expect(screen.getAllByText('banking').length).toBeGreaterThanOrEqual(1)
120+
})
121+
122+
it('renders tabs for pattern type', () => {
123+
renderDetail('fiat-current-account')
124+
expect(screen.getByRole('tab', { name: 'Manifest' })).toBeInTheDocument()
125+
expect(screen.getByRole('tab', { name: 'Starlark' })).toBeInTheDocument()
126+
expect(screen.getByRole('tab', { name: 'Composition' })).toBeInTheDocument()
127+
})
128+
129+
it('shows placeholder for UI component type', () => {
130+
renderDetail('transaction-table')
131+
expect(screen.getByText(/UI component preview/)).toBeInTheDocument()
132+
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
133+
})
134+
135+
it('renders breadcrumb navigation', () => {
136+
renderDetail('fiat-current-account')
137+
const breadcrumb = screen.getByLabelText('Breadcrumb')
138+
expect(breadcrumb).toBeInTheDocument()
139+
expect(screen.getByText('Cookbook')).toBeInTheDocument()
140+
// Title appears in both breadcrumb and heading
141+
expect(screen.getAllByText('Fiat Current Account').length).toBe(2)
142+
})
143+
144+
it('shows no manifest message when file not found', () => {
145+
renderDetail('fiat-current-account')
146+
expect(screen.getByText(/No manifest file found/)).toBeInTheDocument()
147+
})
148+
149+
it('renders manifest viewer when content available', () => {
150+
mockUsePatternFiles.mockReturnValue({
151+
starlarkContent: null,
152+
manifestContent: 'name: test\ntype: registry:pattern',
153+
isLoading: false,
154+
})
155+
renderDetail('fiat-current-account')
156+
expect(screen.getByTestId('manifest-viewer')).toBeInTheDocument()
157+
})
158+
})

0 commit comments

Comments
 (0)