Skip to content

Commit fed43c9

Browse files
authored
feat: cookbook pattern detail view (#1447)
* 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 * fix: use useReducer in usePatternFiles to satisfy React compiler Replace multiple useState calls with useReducer to avoid cascading renders from synchronous setState within useEffect. * fix: add clipboard error handling and timeout cleanup in ManifestViewer Wrap clipboard.writeText in try/catch for non-HTTPS environments. Clear copy timeout on unmount and re-copy to prevent stale setState. * fix: encode URL segments for pattern names with special characters Encode patternName in fetch URLs and composition link paths to prevent broken navigation for names containing reserved characters. * fix: reset state on empty patternName and use typed composition links Clear stale file content when patternName becomes undefined. Use explicit linkTo property instead of colon-presence heuristic for determining which composition items are navigable. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent b644f3c commit fed43c9

7 files changed

Lines changed: 626 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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
18+
const [copied, setCopied] = useState(false)
19+
20+
useEffect(() => {
21+
if (!editorRef.current) return
22+
23+
const view = new EditorView({
24+
state: EditorState.create({
25+
doc: content,
26+
extensions: [
27+
basicSetup,
28+
EditorView.editable.of(false),
29+
EditorState.readOnly.of(true),
30+
],
31+
}),
32+
parent: editorRef.current,
33+
})
34+
35+
viewRef.current = view
36+
37+
return () => {
38+
view.destroy()
39+
viewRef.current = null
40+
}
41+
}, [content])
42+
43+
useEffect(() => {
44+
return () => {
45+
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
46+
}
47+
}, [])
48+
49+
const handleCopy = useCallback(async () => {
50+
try {
51+
await navigator.clipboard.writeText(content)
52+
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
53+
setCopied(true)
54+
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
55+
} catch {
56+
// Clipboard access denied (non-HTTPS or permission denied)
57+
}
58+
}, [content])
59+
60+
return (
61+
<div data-testid="manifest-viewer" className={cn('relative', className)}>
62+
<Button
63+
variant="ghost"
64+
size="sm"
65+
className="absolute right-2 top-2 z-10 h-7 w-7 p-0"
66+
onClick={handleCopy}
67+
aria-label="Copy manifest"
68+
>
69+
{copied ? (
70+
<Check className="h-3.5 w-3.5 text-green-600" />
71+
) : (
72+
<Copy className="h-3.5 w-3.5" />
73+
)}
74+
</Button>
75+
<div
76+
ref={editorRef}
77+
className="min-h-[200px] rounded border border-input bg-background text-sm"
78+
/>
79+
</div>
80+
)
81+
}
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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useEffect, useReducer } from 'react'
2+
3+
interface PatternFilesState {
4+
starlarkContent: string | null
5+
manifestContent: string | null
6+
isLoading: boolean
7+
}
8+
9+
type PatternFilesAction =
10+
| { type: 'reset' }
11+
| { type: 'fetch_start' }
12+
| { type: 'fetch_done'; starlark: string | null; manifest: string | null }
13+
14+
const initialState: PatternFilesState = {
15+
starlarkContent: null,
16+
manifestContent: null,
17+
isLoading: false,
18+
}
19+
20+
function reducer(_state: PatternFilesState, action: PatternFilesAction): PatternFilesState {
21+
switch (action.type) {
22+
case 'reset':
23+
return initialState
24+
case 'fetch_start':
25+
return { starlarkContent: null, manifestContent: null, isLoading: true }
26+
case 'fetch_done':
27+
return { starlarkContent: action.starlark, manifestContent: action.manifest, isLoading: false }
28+
}
29+
}
30+
31+
export function usePatternFiles(patternName: string | undefined): PatternFilesState {
32+
const [state, dispatch] = useReducer(reducer, initialState)
33+
34+
useEffect(() => {
35+
if (!patternName) {
36+
dispatch({ type: 'reset' })
37+
return
38+
}
39+
40+
let cancelled = false
41+
dispatch({ type: 'fetch_start' })
42+
43+
const fetchFile = async (path: string): Promise<string | null> => {
44+
try {
45+
const res = await fetch(path)
46+
if (!res.ok) return null
47+
return await res.text()
48+
} catch {
49+
return null
50+
}
51+
}
52+
53+
const encoded = encodeURIComponent(patternName)
54+
Promise.all([
55+
fetchFile(`/cookbook/patterns/${encoded}/saga.star`),
56+
fetchFile(`/cookbook/patterns/${encoded}/manifest.yaml`),
57+
]).then(([star, yaml]) => {
58+
if (cancelled) return
59+
dispatch({ type: 'fetch_done', starlark: star, manifest: yaml })
60+
})
61+
62+
return () => {
63+
cancelled = true
64+
}
65+
}, [patternName])
66+
67+
return state
68+
}

frontend/src/features/cookbook/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export { CookbookGraphPage } from './pages/graph'
44
export { CompositionGraph } from './components/composition-graph'
55
export type { CookbookItem, CookbookFile, CookbookRegistry, PatternMeta, ComponentMeta } from './hooks/use-cookbook'
66
export { useCookbook } from './hooks/use-cookbook'
7+
export { usePatternFiles } from './hooks/use-pattern-files'
8+
export { ManifestViewer } from './components/manifest-viewer'
79
export { parseStarlarkSaga } from './lib/star-parser'
810
export type { SagaFlow, SagaFlowStep, ServiceCall, EarlyExit } from './lib/star-parser'
911
export { generateMermaidMarkup } from './lib/saga-mermaid'

0 commit comments

Comments
 (0)