aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/FeedItems.tsx16
-rw-r--r--frontend/src/components/FeedList.css36
-rw-r--r--frontend/src/components/FeedList.test.tsx39
-rw-r--r--frontend/src/components/FeedList.tsx66
-rw-r--r--frontend/src/components/TagView.test.tsx81
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');
+ });
+});