diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-13 07:46:58 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-13 09:38:38 -0800 |
| commit | 23a48e1d498680be769e931f46ddb1fd44f38d1a (patch) | |
| tree | 54bb607e19b3eec0e5c932e6748d9ca6304d4b17 /frontend/src/components | |
| parent | a5cd9538b0db731a0d0e10e58804ef8ad32211b7 (diff) | |
| download | neko-23a48e1d498680be769e931f46ddb1fd44f38d1a.tar.gz neko-23a48e1d498680be769e931f46ddb1fd44f38d1a.tar.bz2 neko-23a48e1d498680be769e931f46ddb1fd44f38d1a.zip | |
Implement Tag View and fix tests
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 16 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.css | 36 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.test.tsx | 39 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.tsx | 66 | ||||
| -rw-r--r-- | frontend/src/components/TagView.test.tsx | 81 |
5 files changed, 200 insertions, 38 deletions
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index e6f0a84..01a24fc 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -5,7 +5,7 @@ import FeedItem from './FeedItem'; import './FeedItems.css'; export default function FeedItems() { - const { feedId } = useParams<{ feedId: string }>(); + const { feedId, tagName } = useParams<{ feedId: string; tagName: string }>(); const [items, setItems] = useState<Item[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -14,9 +14,12 @@ export default function FeedItems() { setLoading(true); setError(''); - const url = feedId - ? `/api/stream?feed_id=${feedId}` - : '/api/stream'; // Default or "all" view? For now let's assume we need a feedId or handle "all" logic later + let url = '/api/stream'; + if (feedId) { + url = `/api/stream?feed_id=${feedId}`; + } else if (tagName) { + url = `/api/stream?tag=${encodeURIComponent(tagName)}`; + } fetch(url) .then((res) => { @@ -33,15 +36,14 @@ export default function FeedItems() { setError(err.message); setLoading(false); }); - }, [feedId]); + }, [feedId, tagName]); if (loading) return <div className="feed-items-loading">Loading items...</div>; if (error) return <div className="feed-items-error">Error: {error}</div>; return ( <div className="feed-items"> - <h2>Items</h2> - {/* TODO: Add Feed Title here if possible, maybe pass from location state or fetch feed details */} + <h2>{tagName ? `Tag: ${tagName}` : 'Items'}</h2> {items.length === 0 ? ( <p>No items found.</p> ) : ( diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css index f35ed59..485fab3 100644 --- a/frontend/src/components/FeedList.css +++ b/frontend/src/components/FeedList.css @@ -45,4 +45,38 @@ border-radius: 4px; font-size: 0.8rem; color: #666; -}
\ No newline at end of file +} +.tag-section { + margin-top: 2rem; + border-top: 1px solid #eee; + padding-top: 1rem; +} + +.tag-list-items { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag-item { + display: inline-block; +} + +.tag-link { + display: block; + padding: 0.3rem 0.6rem; + background: #e9ecef; + border-radius: 12px; + text-decoration: none; + color: #333; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.tag-link:hover { + background: #dde2e6; + color: #000; +} diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx index 92ff345..eba9b88 100644 --- a/frontend/src/components/FeedList.test.tsx +++ b/frontend/src/components/FeedList.test.tsx @@ -28,9 +28,20 @@ describe('FeedList Component', () => { { _id: 2, title: 'Feed Two', url: 'http://test.com/rss', web_url: 'http://test.com', category: 'News' }, ]; - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockFeeds, + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/feed/')) { + return Promise.resolve({ + ok: true, + json: async () => mockFeeds, + }); + } + if (url.includes('/api/tag')) { + return Promise.resolve({ + ok: true, + json: async () => [{ title: 'Tech' }], + }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); }); render( @@ -42,12 +53,13 @@ describe('FeedList Component', () => { await waitFor(() => { expect(screen.getByText('Feed One')).toBeInTheDocument(); expect(screen.getByText('Feed Two')).toBeInTheDocument(); - expect(screen.getByText('Tech')).toBeInTheDocument(); + const techElements = screen.getAllByText('Tech'); + expect(techElements.length).toBeGreaterThan(0); }); }); it('handles fetch error', async () => { - (global.fetch as any).mockRejectedValueOnce(new Error('API Error')); + (global.fetch as any).mockImplementation(() => Promise.reject(new Error('API Error'))); render( <BrowserRouter> @@ -61,9 +73,20 @@ describe('FeedList Component', () => { }); it('handles empty feed list', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => [], + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/feed/')) { + return Promise.resolve({ + ok: true, + json: async () => [], + }); + } + if (url.includes('/api/tag')) { + return Promise.resolve({ + ok: true, + json: async () => [], + }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); }); render( diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index f913293..f17fdc7 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -1,23 +1,28 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import type { Feed } from '../types'; +import type { Feed, Category } from '../types'; import './FeedList.css'; export default function FeedList() { const [feeds, setFeeds] = useState<Feed[]>([]); + const [tags, setTags] = useState<Category[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useEffect(() => { - fetch('/api/feed/') - .then((res) => { - if (!res.ok) { - throw new Error('Failed to fetch feeds'); - } + Promise.all([ + fetch('/api/feed/').then(res => { + if (!res.ok) throw new Error('Failed to fetch feeds'); + return res.json(); + }), + fetch('/api/tag').then(res => { + if (!res.ok) throw new Error('Failed to fetch tags'); return res.json(); }) - .then((data) => { - setFeeds(data); + ]) + .then(([feedsData, tagsData]) => { + setFeeds(feedsData); + setTags(tagsData); setLoading(false); }) .catch((err) => { @@ -31,20 +36,37 @@ export default function FeedList() { return ( <div className="feed-list"> - <h2>Feeds</h2> - {feeds.length === 0 ? ( - <p>No feeds found.</p> - ) : ( - <ul className="feed-list-items"> - {feeds.map((feed) => ( - <li key={feed._id} className="feed-item"> - <Link to={`/feed/${feed._id}`} className="feed-title"> - {feed.title || feed.url} - </Link> - {feed.category && <span className="feed-category">{feed.category}</span>} - </li> - ))} - </ul> + <div className="feed-section"> + <h2>Feeds</h2> + {feeds.length === 0 ? ( + <p>No feeds found.</p> + ) : ( + <ul className="feed-list-items"> + {feeds.map((feed) => ( + <li key={feed._id} className="feed-item"> + <Link to={`/feed/${feed._id}`} className="feed-title"> + {feed.title || feed.url} + </Link> + {feed.category && <span className="feed-category">{feed.category}</span>} + </li> + ))} + </ul> + )} + </div> + + {tags && tags.length > 0 && ( + <div className="tag-section"> + <h2>Tags</h2> + <ul className="tag-list-items"> + {tags.map((tag) => ( + <li key={tag.title} className="tag-item"> + <Link to={`/tag/${encodeURIComponent(tag.title)}`} className="tag-link"> + {tag.title} + </Link> + </li> + ))} + </ul> + </div> )} </div> ); diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx new file mode 100644 index 0000000..8a724cd --- /dev/null +++ b/frontend/src/components/TagView.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import FeedList from './FeedList'; +import FeedItems from './FeedItems'; + +describe('Tag View Integration', () => { + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + it('renders tags in FeedList and navigates to tag view', async () => { + const mockFeeds = [{ _id: 1, title: 'Feed 1', url: 'http://example.com/rss', category: 'Tech' }]; + const mockTags = [{ title: 'Tech' }, { title: 'News' }]; + + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/feed/')) { + return Promise.resolve({ + ok: true, + json: async () => mockFeeds, + }); + } + if (url.includes('/api/tag')) { + return Promise.resolve({ + ok: true, + json: async () => mockTags, + }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); + + render( + <MemoryRouter> + <FeedList /> + </MemoryRouter> + ); + + await waitFor(() => { + const techTags = screen.getAllByText('Tech'); + expect(techTags.length).toBeGreaterThan(0); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + // Verify structure + const techTag = screen.getByText('News').closest('a'); + expect(techTag).toHaveAttribute('href', '/tag/News'); + }); + + it('fetches items by tag in FeedItems', async () => { + const mockItems = [ + { _id: 101, title: 'Tag Item 1', url: 'http://example.com/1', feed_title: 'Feed 1' } + ]; + + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/stream')) { + return Promise.resolve({ + ok: true, + json: async () => mockItems, + }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); + + render( + <MemoryRouter initialEntries={['/tag/Tech']}> + <Routes> + <Route path="/tag/:tagName" element={<FeedItems />} /> + </Routes> + </MemoryRouter> + ); + + await waitFor(() => { + expect(screen.getByText('Tag: Tech')).toBeInTheDocument(); + expect(screen.getByText('Tag Item 1')).toBeInTheDocument(); + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/stream?tag=Tech'); + }); +}); |
