From 3ba71500bc2d60a00ca81b9439305029670f4d52 Mon Sep 17 00:00:00 2001
From: Adam Mathes
Date: Fri, 13 Feb 2026 06:55:21 -0800
Subject: Implement Frontend Feed Items View with tests
---
frontend/src/App.tsx | 6 ++-
frontend/src/components/FeedItems.css | 60 +++++++++++++++++++++++++++
frontend/src/components/FeedItems.test.tsx | 52 +++++++++++++++++++++++
frontend/src/components/FeedItems.tsx | 66 ++++++++++++++++++++++++++++++
frontend/src/components/FeedList.test.tsx | 26 ++++++++++--
frontend/src/components/FeedList.tsx | 5 ++-
frontend/src/types.ts | 14 +++++++
7 files changed, 222 insertions(+), 7 deletions(-)
create mode 100644 frontend/src/components/FeedItems.css
create mode 100644 frontend/src/components/FeedItems.test.tsx
create mode 100644 frontend/src/components/FeedItems.tsx
(limited to 'frontend/src')
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7d26025..bc4e097 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,6 +32,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) {
}
import FeedList from './components/FeedList';
+import FeedItems from './components/FeedItems';
function Dashboard() {
return (
@@ -47,7 +48,10 @@ function Dashboard() {
- Select a feed to view items.
+
+ } />
+ Select a feed to view items.
} />
+
diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css
new file mode 100644
index 0000000..a057a40
--- /dev/null
+++ b/frontend/src/components/FeedItems.css
@@ -0,0 +1,60 @@
+.feed-items {
+ padding: 1rem;
+}
+
+.feed-items h2 {
+ margin-top: 0;
+ border-bottom: 2px solid #eee;
+ padding-bottom: 0.5rem;
+}
+
+.item-list {
+ list-style: none;
+ padding: 0;
+}
+
+.item {
+ border-bottom: 1px solid #f0f0f0;
+ padding: 1rem 0;
+}
+
+.item.read .item-title {
+ color: #888;
+ font-weight: normal;
+}
+
+.item.unread .item-title {
+ font-weight: bold;
+}
+
+.item-title {
+ font-size: 1.2rem;
+ text-decoration: none;
+ color: #333;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.item-title:hover {
+ text-decoration: underline;
+ color: #007bff;
+}
+
+.item-meta {
+ font-size: 0.85rem;
+ color: #666;
+ margin-bottom: 0.5rem;
+}
+
+.item-description {
+ color: #444;
+ line-height: 1.5;
+ font-size: 0.95rem;
+}
+
+.item-description img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 1rem 0;
+}
\ No newline at end of file
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
new file mode 100644
index 0000000..9c271c4
--- /dev/null
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import FeedItems from './FeedItems';
+
+describe('FeedItems Component', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it('renders loading state', () => {
+ (global.fetch as any).mockImplementation(() => new Promise(() => { }));
+ render(
+
+
+ } />
+
+
+ );
+ expect(screen.getByText(/loading items/i)).toBeInTheDocument();
+ });
+
+ it('renders items for a feed', async () => {
+ const mockItems = [
+ { _id: 101, title: 'Item One', url: 'http://example.com/1', publish_date: '2023-01-01', read: false },
+ { _id: 102, title: 'Item Two', url: 'http://example.com/2', publish_date: '2023-01-02', read: true },
+ ];
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockItems,
+ });
+
+ render(
+
+
+ } />
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item One')).toBeInTheDocument();
+ expect(screen.getByText('Item Two')).toBeInTheDocument();
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith('/api/stream?feed_id=1');
+ });
+});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
new file mode 100644
index 0000000..048bed7
--- /dev/null
+++ b/frontend/src/components/FeedItems.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import type { Item } from '../types';
+import './FeedItems.css';
+
+export default function FeedItems() {
+ const { feedId } = useParams<{ feedId: string }>();
+ const [items, setItems] = useState- ([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ 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
+
+ fetch(url)
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error('Failed to fetch items');
+ }
+ return res.json();
+ })
+ .then((data) => {
+ setItems(data);
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }, [feedId]);
+
+ 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 */}
+ {items.length === 0 ? (
+
No items found.
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx
index 578e3c2..92ff345 100644
--- a/frontend/src/components/FeedList.test.tsx
+++ b/frontend/src/components/FeedList.test.tsx
@@ -4,6 +4,8 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import FeedList from './FeedList';
+import { BrowserRouter } from 'react-router-dom';
+
describe('FeedList Component', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -12,7 +14,11 @@ describe('FeedList Component', () => {
it('renders loading state initially', () => {
(global.fetch as any).mockImplementation(() => new Promise(() => { }));
- render();
+ render(
+
+
+
+ );
expect(screen.getByText(/loading feeds/i)).toBeInTheDocument();
});
@@ -27,7 +33,11 @@ describe('FeedList Component', () => {
json: async () => mockFeeds,
});
- render();
+ render(
+
+
+
+ );
await waitFor(() => {
expect(screen.getByText('Feed One')).toBeInTheDocument();
@@ -39,7 +49,11 @@ describe('FeedList Component', () => {
it('handles fetch error', async () => {
(global.fetch as any).mockRejectedValueOnce(new Error('API Error'));
- render();
+ render(
+
+
+
+ );
await waitFor(() => {
expect(screen.getByText(/error: api error/i)).toBeInTheDocument();
@@ -52,7 +66,11 @@ describe('FeedList Component', () => {
json: async () => [],
});
- render();
+ render(
+
+
+
+ );
await waitFor(() => {
expect(screen.getByText(/no feeds found/i)).toBeInTheDocument();
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index fb7c1de..f913293 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
import type { Feed } from '../types';
import './FeedList.css';
@@ -37,9 +38,9 @@ export default function FeedList() {
{feeds.map((feed) => (
-
-
+
{feed.title || feed.url}
-
+
{feed.category && {feed.category}}
))}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 905b1dc..872e608 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -5,3 +5,17 @@ export interface Feed {
title: string;
category: string;
}
+
+export interface Item {
+ _id: number;
+ feed_id: number;
+ title: string;
+ url: string;
+ description: string;
+ publish_date: string;
+ read: boolean;
+ starred: boolean;
+ full_content?: string;
+ header_image?: string;
+ feed_title?: string;
+}
--
cgit v1.2.3