From 23a48e1d498680be769e931f46ddb1fd44f38d1a Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Fri, 13 Feb 2026 07:46:58 -0800 Subject: Implement Tag View and fix tests --- frontend/src/components/FeedItems.tsx | 16 +++--- frontend/src/components/FeedList.css | 36 +++++++++++++- frontend/src/components/FeedList.test.tsx | 39 ++++++++++++--- frontend/src/components/FeedList.tsx | 66 ++++++++++++++++--------- frontend/src/components/TagView.test.tsx | 81 +++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/TagView.test.tsx (limited to 'frontend/src/components') 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([]); 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
Loading items...
; if (error) return
Error: {error}
; return (
-

Items

- {/* TODO: Add Feed Title here if possible, maybe pass from location state or fetch feed details */} +

{tagName ? `Tag: ${tagName}` : 'Items'}

{items.length === 0 ? (

No items found.

) : ( 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( @@ -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([]); + const [tags, setTags] = useState([]); 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 (
-

Feeds

- {feeds.length === 0 ? ( -

No feeds found.

- ) : ( -
    - {feeds.map((feed) => ( -
  • - - {feed.title || feed.url} - - {feed.category && {feed.category}} -
  • - ))} -
+
+

Feeds

+ {feeds.length === 0 ? ( +

No feeds found.

+ ) : ( +
    + {feeds.map((feed) => ( +
  • + + {feed.title || feed.url} + + {feed.category && {feed.category}} +
  • + ))} +
+ )} +
+ + {tags && tags.length > 0 && ( +
+

Tags

+
    + {tags.map((tag) => ( +
  • + + {tag.title} + +
  • + ))} +
+
)}
); 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( + + + + ); + + 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( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText('Tag: Tech')).toBeInTheDocument(); + expect(screen.getByText('Tag Item 1')).toBeInTheDocument(); + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/stream?tag=Tech'); + }); +}); -- cgit v1.2.3