Skip to content

Commit eb7946f

Browse files
committed
feat: implement blog post list with filtering and search
1 parent 8c8e840 commit eb7946f

3 files changed

Lines changed: 259 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import { BlogPostList } from '../blog/blog-post-list';
3+
import { BlogPost } from '@/types';
4+
5+
// Mock data for testing
6+
const mockPosts: BlogPost[] = [
7+
{
8+
title: 'Building Offline-First Apps',
9+
slug: 'building-offline-first-apps',
10+
date: new Date('2025-01-15'),
11+
excerpt: 'Real-time synchronization strategies for mobile applications',
12+
tags: ['React Native', 'Offline', 'Sync'],
13+
category: 'Mobile Development',
14+
content: 'Full content here...',
15+
readingTime: 8,
16+
published: true,
17+
},
18+
{
19+
title: 'Microservices Architecture Patterns',
20+
slug: 'microservices-architecture-patterns',
21+
date: new Date('2025-01-10'),
22+
excerpt: 'Best practices for designing scalable microservices',
23+
tags: ['Microservices', 'Architecture', 'Scalability'],
24+
category: 'Backend Development',
25+
content: 'Full content here...',
26+
readingTime: 12,
27+
published: true,
28+
},
29+
{
30+
title: 'Modern CSS Grid Layouts',
31+
slug: 'modern-css-grid-layouts',
32+
date: new Date('2025-01-05'),
33+
excerpt: 'Advanced CSS Grid techniques for responsive design',
34+
tags: ['CSS', 'Grid', 'Responsive'],
35+
category: 'Frontend Development',
36+
content: 'Full content here...',
37+
readingTime: 6,
38+
published: true,
39+
},
40+
];
41+
42+
describe('BlogPostList', () => {
43+
it('renders list of blog posts', () => {
44+
render(<BlogPostList posts={mockPosts} />);
45+
46+
expect(screen.getByText('Building Offline-First Apps')).toBeInTheDocument();
47+
expect(screen.getByText('Microservices Architecture Patterns')).toBeInTheDocument();
48+
expect(screen.getByText('Modern CSS Grid Layouts')).toBeInTheDocument();
49+
});
50+
51+
it('displays category filter badges', () => {
52+
render(<BlogPostList posts={mockPosts} />);
53+
54+
// Use getAllByText to get all instances and check filter badges specifically
55+
const mobileBadges = screen.getAllByText('Mobile Development');
56+
const backendBadges = screen.getAllByText('Backend Development');
57+
const frontendBadges = screen.getAllByText('Frontend Development');
58+
59+
expect(mobileBadges.length).toBeGreaterThan(0);
60+
expect(backendBadges.length).toBeGreaterThan(0);
61+
expect(frontendBadges.length).toBeGreaterThan(0);
62+
});
63+
64+
it('filters posts by category when badge is clicked', async () => {
65+
render(<BlogPostList posts={mockPosts} />);
66+
67+
// Get the first Mobile Development badge (filter badge)
68+
const mobileBadges = screen.getAllByText('Mobile Development');
69+
const mobileBadge = mobileBadges[0]; // Filter badge is first
70+
fireEvent.click(mobileBadge);
71+
72+
await waitFor(() => {
73+
expect(screen.getByText('Building Offline-First Apps')).toBeInTheDocument();
74+
expect(screen.queryByText('Microservices Architecture Patterns')).not.toBeInTheDocument();
75+
expect(screen.queryByText('Modern CSS Grid Layouts')).not.toBeInTheDocument();
76+
});
77+
});
78+
79+
it('filters posts by search term', async () => {
80+
render(<BlogPostList posts={mockPosts} />);
81+
82+
const searchInput = screen.getByPlaceholderText('Search posts...');
83+
fireEvent.change(searchInput, { target: { value: 'offline' } });
84+
85+
await waitFor(() => {
86+
expect(screen.getByText('Building Offline-First Apps')).toBeInTheDocument();
87+
expect(screen.queryByText('Microservices Architecture Patterns')).not.toBeInTheDocument();
88+
expect(screen.queryByText('Modern CSS Grid Layouts')).not.toBeInTheDocument();
89+
});
90+
});
91+
92+
it('sorts posts by date (newest first)', () => {
93+
render(<BlogPostList posts={mockPosts} />);
94+
95+
const posts = screen.getAllByTestId('blog-post-card');
96+
expect(posts).toHaveLength(3);
97+
98+
// Check that the first post is the newest (2025-01-15)
99+
expect(screen.getByText('Building Offline-First Apps')).toBeInTheDocument();
100+
});
101+
102+
it('displays empty state when no posts match filters', async () => {
103+
render(<BlogPostList posts={mockPosts} />);
104+
105+
const searchInput = screen.getByPlaceholderText('Search posts...');
106+
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
107+
108+
await waitFor(() => {
109+
expect(screen.getByText('No posts found')).toBeInTheDocument();
110+
expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument();
111+
});
112+
});
113+
114+
it('has responsive grid layout classes', () => {
115+
render(<BlogPostList posts={mockPosts} />);
116+
117+
const gridContainer = screen.getByTestId('blog-posts-grid');
118+
expect(gridContainer).toHaveClass('grid', 'grid-cols-1', 'md:grid-cols-2', 'lg:grid-cols-3');
119+
});
120+
121+
it('clears filters when clear button is clicked', async () => {
122+
render(<BlogPostList posts={mockPosts} />);
123+
124+
// Apply a filter
125+
const searchInput = screen.getByPlaceholderText('Search posts...');
126+
fireEvent.change(searchInput, { target: { value: 'offline' } });
127+
128+
await waitFor(() => {
129+
expect(screen.queryByText('Microservices Architecture Patterns')).not.toBeInTheDocument();
130+
});
131+
132+
// Clear filters
133+
const clearButton = screen.getByText('Clear filters');
134+
fireEvent.click(clearButton);
135+
136+
await waitFor(() => {
137+
expect(screen.getByText('Microservices Architecture Patterns')).toBeInTheDocument();
138+
expect(screen.getByText('Modern CSS Grid Layouts')).toBeInTheDocument();
139+
});
140+
});
141+
142+
it('debounces search input', async () => {
143+
jest.useFakeTimers();
144+
render(<BlogPostList posts={mockPosts} />);
145+
146+
const searchInput = screen.getByPlaceholderText('Search posts...');
147+
fireEvent.change(searchInput, { target: { value: 'o' } });
148+
fireEvent.change(searchInput, { target: { value: 'of' } });
149+
fireEvent.change(searchInput, { target: { value: 'off' } });
150+
151+
// Fast forward timers to trigger debounced search
152+
jest.runAllTimers();
153+
154+
await waitFor(() => {
155+
expect(screen.getByText('Building Offline-First Apps')).toBeInTheDocument();
156+
});
157+
158+
jest.useRealTimers();
159+
});
160+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Badge } from '@/components/ui/Badge';
2+
import { Button } from '@/components/ui/Button';
3+
4+
interface BlogFiltersProps {
5+
categories: string[];
6+
selectedCategory: string;
7+
onCategoryChange: (cat: string) => void;
8+
searchTerm: string;
9+
onSearchChange: (val: string) => void;
10+
onClear: () => void;
11+
showClear: boolean;
12+
}
13+
14+
export function BlogFilters({
15+
categories,
16+
selectedCategory,
17+
onCategoryChange,
18+
searchTerm,
19+
onSearchChange,
20+
onClear,
21+
showClear,
22+
}: BlogFiltersProps) {
23+
return (
24+
<div className="space-y-4">
25+
<input
26+
type="text"
27+
placeholder="Search posts..."
28+
value={searchTerm}
29+
onChange={e => onSearchChange(e.target.value)}
30+
className="w-full px-4 py-2 border border-muted rounded-lg bg-surface text-text focus:outline-none focus:ring-2 focus:ring-accent"
31+
/>
32+
<div className="flex flex-wrap gap-2">
33+
{categories.map(category => (
34+
<Badge
35+
key={category}
36+
variant={selectedCategory === category ? 'default' : 'outline'}
37+
className={`cursor-pointer transition-colors ${selectedCategory === category ? 'bg-accent text-white' : 'text-accent hover:bg-accent hover:text-white'}`}
38+
onClick={() => onCategoryChange(selectedCategory === category ? '' : category)}
39+
>
40+
{category}
41+
</Badge>
42+
))}
43+
</div>
44+
{showClear && (
45+
<Button variant="outline" size="sm" onClick={onClear} className="text-muted hover:text-text">
46+
Clear filters
47+
</Button>
48+
)}
49+
</div>
50+
);
51+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useState, useMemo } from 'react';
2+
import { BlogPost } from '@/types';
3+
import { BlogPostCard } from './blog-post-card';
4+
import { BlogFilters } from './blog-filters';
5+
6+
interface BlogPostListProps {
7+
posts: BlogPost[];
8+
}
9+
10+
export function BlogPostList({ posts }: BlogPostListProps) {
11+
const [selectedCategory, setSelectedCategory] = useState('');
12+
const [searchTerm, setSearchTerm] = useState('');
13+
14+
const categories = useMemo(() => [...new Set(posts.map(p => p.category))].sort(), [posts]);
15+
const filteredPosts = useMemo(() => {
16+
let filtered = posts.filter(p => p.published);
17+
if (selectedCategory) filtered = filtered.filter(p => p.category === selectedCategory);
18+
if (searchTerm) {
19+
const s = searchTerm.toLowerCase();
20+
filtered = filtered.filter(p => p.title.toLowerCase().includes(s) || p.excerpt.toLowerCase().includes(s));
21+
}
22+
return filtered.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
23+
}, [posts, selectedCategory, searchTerm]);
24+
25+
return (
26+
<div className="space-y-6">
27+
<BlogFilters
28+
categories={categories}
29+
selectedCategory={selectedCategory}
30+
onCategoryChange={setSelectedCategory}
31+
searchTerm={searchTerm}
32+
onSearchChange={setSearchTerm}
33+
onClear={() => { setSelectedCategory(''); setSearchTerm(''); }}
34+
showClear={!!selectedCategory || !!searchTerm}
35+
/>
36+
{filteredPosts.length > 0 ? (
37+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" data-testid="blog-posts-grid">
38+
{filteredPosts.map(post => <BlogPostCard key={post.slug} post={post} />)}
39+
</div>
40+
) : (
41+
<div className="text-center py-12">
42+
<h3 className="text-lg font-semibold text-text mb-2">No posts found</h3>
43+
<p className="text-muted">Try adjusting your search or filters</p>
44+
</div>
45+
)}
46+
</div>
47+
);
48+
}

0 commit comments

Comments
 (0)