Skip to content

Commit 7b9a4ad

Browse files
authored
feat: catalogue grid component for cookbook browser (#1444)
* feat: add catalogue grid component with filter bar for cookbook browser - CatalogueGrid: responsive card grid (1-4 cols) with type badges, category badges, complexity indicators, and click-to-navigate - FilterBar: search input, type/category/industry chip filters with URL query param sync via useSearchParams - applyFilters: pure filtering logic with AND semantics - Empty states for no items and no filter matches - 20 vitest tests covering filter logic and grid rendering * fix: address CodeRabbit review - keyboard accessibility and type validation - Add role, tabIndex, and onKeyDown to CookbookCard for keyboard nav - Wrap filter chips in <button> with aria-pressed for accessibility - Handle unknown type query param values explicitly (filter returns empty) - Add test for invalid type filter value * fix: resolve eslint errors - split filter logic from components - Move useFilterState and applyFilters to hooks/use-filter-state.ts to satisfy react-refresh/only-export-components rule - Remove typeof import() annotation in test to fix consistent-type-imports rule * fix: only render CardContent when categories exist Separate the categories guard from complexity check to prevent rendering an empty CardContent section. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 7d57587 commit 7b9a4ad

6 files changed

Lines changed: 540 additions & 1 deletion

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { MemoryRouter } from 'react-router-dom'
5+
import { renderWithProviders } from '@/test/test-utils'
6+
import { CatalogueGrid } from './catalogue-grid'
7+
import type { CookbookItem } from '../hooks/use-cookbook'
8+
9+
vi.mock('@/api/transport', () => ({
10+
createTenantTransport: vi.fn(() => ({ __type: 'mock-transport' })),
11+
}))
12+
13+
vi.mock('@/api/clients', () => ({
14+
createServiceClients: vi.fn(() => ({})),
15+
}))
16+
17+
const mockItems: CookbookItem[] = [
18+
{
19+
name: 'energy-trading',
20+
type: 'registry:pattern',
21+
title: 'Energy Trading',
22+
description: 'Buy and sell electricity',
23+
categories: ['energy', 'trading'],
24+
meta: { complexity: 7, design_pattern: 'saga', industries: ['energy'] },
25+
},
26+
{
27+
name: 'balance-card',
28+
type: 'registry:ui',
29+
title: 'Balance Card',
30+
description: 'Displays account balance',
31+
categories: ['accounts'],
32+
meta: { feature_module: 'accounts', configurable: true },
33+
},
34+
]
35+
36+
const mockNavigate = vi.fn()
37+
vi.mock('react-router-dom', async () => {
38+
const actual = await vi.importActual('react-router-dom')
39+
return { ...actual, useNavigate: () => mockNavigate }
40+
})
41+
42+
function renderGrid(items: CookbookItem[], hasActiveFilters = false) {
43+
return renderWithProviders(
44+
<MemoryRouter>
45+
<CatalogueGrid items={items} hasActiveFilters={hasActiveFilters} />
46+
</MemoryRouter>,
47+
)
48+
}
49+
50+
describe('CatalogueGrid', () => {
51+
it('renders cards for each item', () => {
52+
renderGrid(mockItems)
53+
expect(screen.getByText('Energy Trading')).toBeInTheDocument()
54+
expect(screen.getByText('Balance Card')).toBeInTheDocument()
55+
})
56+
57+
it('shows type badges', () => {
58+
renderGrid(mockItems)
59+
expect(screen.getByText('Pattern')).toBeInTheDocument()
60+
expect(screen.getByText('UI')).toBeInTheDocument()
61+
})
62+
63+
it('shows category badges', () => {
64+
renderGrid(mockItems)
65+
expect(screen.getByText('energy')).toBeInTheDocument()
66+
expect(screen.getByText('trading')).toBeInTheDocument()
67+
expect(screen.getByText('accounts')).toBeInTheDocument()
68+
})
69+
70+
it('shows empty state when no items', () => {
71+
renderGrid([])
72+
expect(screen.getByText('No cookbook entries yet')).toBeInTheDocument()
73+
})
74+
75+
it('shows filter-specific empty state', () => {
76+
renderGrid([], true)
77+
expect(screen.getByText('No matching items')).toBeInTheDocument()
78+
})
79+
80+
it('navigates on card click', async () => {
81+
const user = userEvent.setup()
82+
renderGrid(mockItems)
83+
await user.click(screen.getByText('Energy Trading'))
84+
expect(mockNavigate).toHaveBeenCalledWith('/cookbook/energy-trading')
85+
})
86+
87+
it('shows complexity indicator for patterns', () => {
88+
renderGrid(mockItems)
89+
const complexityDots = document.querySelectorAll('[title="Complexity: 7/10"]')
90+
expect(complexityDots.length).toBe(1)
91+
})
92+
93+
it('shows design pattern label', () => {
94+
renderGrid(mockItems)
95+
expect(screen.getByText('saga')).toBeInTheDocument()
96+
})
97+
})
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useNavigate } from 'react-router-dom'
2+
import { Blocks, BookOpen, SearchX } from 'lucide-react'
3+
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
4+
import { Badge } from '@/components/ui/badge'
5+
import { EmptyState } from '@/components/ui/empty-state'
6+
import type { CookbookItem, PatternMeta, ComponentMeta } from '../hooks/use-cookbook'
7+
8+
interface CatalogueGridProps {
9+
items: CookbookItem[]
10+
hasActiveFilters?: boolean
11+
}
12+
13+
function isPatternMeta(meta: PatternMeta | ComponentMeta | undefined): meta is PatternMeta {
14+
return meta !== undefined && 'complexity' in meta
15+
}
16+
17+
function ComplexityIndicator({ score }: { score: number }) {
18+
return (
19+
<div className="flex items-center gap-1" title={`Complexity: ${score}/10`}>
20+
{Array.from({ length: 5 }, (_, i) => (
21+
<div
22+
key={i}
23+
className={`size-1.5 rounded-full ${
24+
i < Math.ceil(score / 2) ? 'bg-primary' : 'bg-muted'
25+
}`}
26+
/>
27+
))}
28+
</div>
29+
)
30+
}
31+
32+
function CookbookCard({ item }: { item: CookbookItem }) {
33+
const navigate = useNavigate()
34+
const isPattern = item.type === 'registry:pattern'
35+
const meta = item.meta
36+
const patternMeta = isPatternMeta(meta) ? meta : undefined
37+
38+
return (
39+
<Card
40+
role="link"
41+
tabIndex={0}
42+
className="cursor-pointer transition-colors hover:border-primary/50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none"
43+
onClick={() => navigate(`/cookbook/${item.name}`)}
44+
onKeyDown={(e) => {
45+
if (e.key === 'Enter' || e.key === ' ') {
46+
e.preventDefault()
47+
navigate(`/cookbook/${item.name}`)
48+
}
49+
}}
50+
>
51+
<CardHeader>
52+
<div className="flex items-start justify-between gap-2">
53+
<div className="flex items-center gap-2">
54+
{isPattern ? (
55+
<BookOpen className="size-4 text-primary shrink-0" />
56+
) : (
57+
<Blocks className="size-4 text-muted-foreground shrink-0" />
58+
)}
59+
<CardTitle className="text-sm">{item.title}</CardTitle>
60+
</div>
61+
<Badge variant={isPattern ? 'default' : 'secondary'} className="text-[10px] shrink-0">
62+
{isPattern ? 'Pattern' : 'UI'}
63+
</Badge>
64+
</div>
65+
{item.description && (
66+
<CardDescription className="line-clamp-2 text-xs">
67+
{item.description}
68+
</CardDescription>
69+
)}
70+
</CardHeader>
71+
72+
{item.categories && item.categories.length > 0 && (
73+
<CardContent>
74+
<div className="flex flex-wrap gap-1">
75+
{item.categories.map((cat) => (
76+
<Badge key={cat} variant="outline" className="text-[10px]">
77+
{cat}
78+
</Badge>
79+
))}
80+
</div>
81+
</CardContent>
82+
)}
83+
84+
{patternMeta?.complexity !== undefined && (
85+
<CardFooter className="justify-between">
86+
<ComplexityIndicator score={patternMeta.complexity} />
87+
{patternMeta.design_pattern && (
88+
<span className="text-[10px] text-muted-foreground">{patternMeta.design_pattern}</span>
89+
)}
90+
</CardFooter>
91+
)}
92+
</Card>
93+
)
94+
}
95+
96+
export function CatalogueGrid({ items, hasActiveFilters }: CatalogueGridProps) {
97+
if (items.length === 0) {
98+
return (
99+
<EmptyState
100+
icon={hasActiveFilters ? SearchX : BookOpen}
101+
title={hasActiveFilters ? 'No matching items' : 'No cookbook entries yet'}
102+
description={
103+
hasActiveFilters
104+
? 'Try adjusting your filters or search terms.'
105+
: 'Cookbook entries will appear here once patterns and components are registered.'
106+
}
107+
/>
108+
)
109+
}
110+
111+
return (
112+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
113+
{items.map((item) => (
114+
<CookbookCard key={item.name} item={item} />
115+
))}
116+
</div>
117+
)
118+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { applyFilters, type FilterState } from '../hooks/use-filter-state'
3+
import type { CookbookItem } from '../hooks/use-cookbook'
4+
5+
const mockItems: CookbookItem[] = [
6+
{
7+
name: 'energy-trading',
8+
type: 'registry:pattern',
9+
title: 'Energy Trading',
10+
description: 'Buy and sell electricity on wholesale markets',
11+
categories: ['energy', 'trading'],
12+
meta: {
13+
complexity: 7,
14+
design_pattern: 'saga',
15+
industries: ['energy', 'utilities'],
16+
},
17+
},
18+
{
19+
name: 'carbon-offset',
20+
type: 'registry:pattern',
21+
title: 'Carbon Offset',
22+
description: 'Track carbon credits and offsets',
23+
categories: ['carbon', 'compliance'],
24+
meta: {
25+
complexity: 5,
26+
industries: ['energy', 'finance'],
27+
},
28+
},
29+
{
30+
name: 'balance-card',
31+
type: 'registry:ui',
32+
title: 'Balance Card',
33+
description: 'Displays account balance with currency formatting',
34+
categories: ['accounts'],
35+
meta: {
36+
feature_module: 'accounts',
37+
configurable: true,
38+
},
39+
},
40+
]
41+
42+
const emptyFilters: FilterState = { search: '', type: '', category: '', industry: '' }
43+
44+
describe('applyFilters', () => {
45+
it('returns all items when no filters active', () => {
46+
const result = applyFilters(mockItems, emptyFilters)
47+
expect(result).toHaveLength(3)
48+
})
49+
50+
it('filters by type pattern', () => {
51+
const result = applyFilters(mockItems, { ...emptyFilters, type: 'pattern' })
52+
expect(result).toHaveLength(2)
53+
expect(result.every((i) => i.type === 'registry:pattern')).toBe(true)
54+
})
55+
56+
it('filters by type ui', () => {
57+
const result = applyFilters(mockItems, { ...emptyFilters, type: 'ui' })
58+
expect(result).toHaveLength(1)
59+
expect(result[0].name).toBe('balance-card')
60+
})
61+
62+
it('filters by category', () => {
63+
const result = applyFilters(mockItems, { ...emptyFilters, category: 'energy' })
64+
expect(result).toHaveLength(1)
65+
expect(result[0].name).toBe('energy-trading')
66+
})
67+
68+
it('filters by industry', () => {
69+
const result = applyFilters(mockItems, { ...emptyFilters, industry: 'finance' })
70+
expect(result).toHaveLength(1)
71+
expect(result[0].name).toBe('carbon-offset')
72+
})
73+
74+
it('filters by search term in title', () => {
75+
const result = applyFilters(mockItems, { ...emptyFilters, search: 'balance' })
76+
expect(result).toHaveLength(1)
77+
expect(result[0].name).toBe('balance-card')
78+
})
79+
80+
it('filters by search term in description', () => {
81+
const result = applyFilters(mockItems, { ...emptyFilters, search: 'electricity' })
82+
expect(result).toHaveLength(1)
83+
expect(result[0].name).toBe('energy-trading')
84+
})
85+
86+
it('search is case-insensitive', () => {
87+
const result = applyFilters(mockItems, { ...emptyFilters, search: 'CARBON' })
88+
expect(result).toHaveLength(1)
89+
expect(result[0].name).toBe('carbon-offset')
90+
})
91+
92+
it('combines multiple filters with AND logic', () => {
93+
const result = applyFilters(mockItems, {
94+
type: 'pattern',
95+
industry: 'energy',
96+
category: '',
97+
search: '',
98+
})
99+
expect(result).toHaveLength(2)
100+
})
101+
102+
it('returns empty when no items match', () => {
103+
const result = applyFilters(mockItems, { ...emptyFilters, search: 'nonexistent' })
104+
expect(result).toHaveLength(0)
105+
})
106+
107+
it('handles items without categories or meta gracefully', () => {
108+
const sparse: CookbookItem[] = [
109+
{ name: 'bare', type: 'registry:pattern', title: 'Bare Item' },
110+
]
111+
const result = applyFilters(sparse, { ...emptyFilters, category: 'anything' })
112+
expect(result).toHaveLength(0)
113+
})
114+
115+
it('handles items without meta.industries for industry filter', () => {
116+
const result = applyFilters(mockItems, { ...emptyFilters, industry: 'utilities' })
117+
expect(result).toHaveLength(1)
118+
expect(result[0].name).toBe('energy-trading')
119+
})
120+
121+
it('filters out all items for unknown type value', () => {
122+
const result = applyFilters(mockItems, { ...emptyFilters, type: 'invalid' })
123+
expect(result).toHaveLength(0)
124+
})
125+
})

0 commit comments

Comments
 (0)