From a5cd9538b0db731a0d0e10e58804ef8ad32211b7 Mon Sep 17 00:00:00 2001
From: Adam Mathes
Date: Fri, 13 Feb 2026 07:13:18 -0800
Subject: Implement Frontend Settings with tests
---
frontend/src/App.tsx | 12 +++
frontend/src/components/Settings.css | 83 ++++++++++++++++++++
frontend/src/components/Settings.test.tsx | 92 +++++++++++++++++++++++
frontend/src/components/Settings.tsx | 121 ++++++++++++++++++++++++++++++
4 files changed, 308 insertions(+)
create mode 100644 frontend/src/components/Settings.css
create mode 100644 frontend/src/components/Settings.test.tsx
create mode 100644 frontend/src/components/Settings.tsx
(limited to 'frontend/src')
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7c9d555..8c7be19 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -33,6 +33,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) {
import FeedList from './components/FeedList';
import FeedItems from './components/FeedItems';
+import Settings from './components/Settings';
function Dashboard() {
return (
@@ -40,6 +41,16 @@ function Dashboard() {
} />
diff --git a/frontend/src/components/Settings.css b/frontend/src/components/Settings.css
new file mode 100644
index 0000000..4065e88
--- /dev/null
+++ b/frontend/src/components/Settings.css
@@ -0,0 +1,83 @@
+.settings-page {
+ padding: 2rem;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.add-feed-section {
+ background: #f9f9f9;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+ border: 1px solid #eee;
+}
+
+.add-feed-form {
+ display: flex;
+ gap: 1rem;
+}
+
+.feed-input {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.error-message {
+ color: #d32f2f;
+ margin-top: 1rem;
+}
+
+.settings-feed-list {
+ list-style: none;
+ padding: 0;
+ border: 1px solid #eee;
+ border-radius: 8px;
+}
+
+.settings-feed-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border-bottom: 1px solid #eee;
+}
+
+.settings-feed-item:last-child {
+ border-bottom: none;
+}
+
+.feed-info {
+ display: flex;
+ flex-direction: column;
+}
+
+.feed-title {
+ font-weight: bold;
+ font-size: 1.1rem;
+}
+
+.feed-url {
+ color: #666;
+ font-size: 0.9rem;
+}
+
+.delete-btn {
+ background: #ff5252;
+ color: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.delete-btn:hover {
+ background: #ff1744;
+}
+
+.delete-btn:disabled {
+ background: #ffcdd2;
+ cursor: not-allowed;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Settings.test.tsx b/frontend/src/components/Settings.test.tsx
new file mode 100644
index 0000000..a15192d
--- /dev/null
+++ b/frontend/src/components/Settings.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import Settings from './Settings';
+
+describe('Settings Component', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ global.fetch = vi.fn();
+ // Mock confirm
+ global.confirm = vi.fn(() => true);
+ });
+
+ it('renders feed list', async () => {
+ const mockFeeds = [
+ { _id: 1, title: 'Tech News', url: 'http://tech.com/rss', category: 'tech' },
+ { _id: 2, title: 'Gaming', url: 'http://gaming.com/rss', category: 'gaming' },
+ ];
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockFeeds,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Tech News')).toBeInTheDocument();
+ expect(screen.getByText('http://tech.com/rss')).toBeInTheDocument();
+ expect(screen.getByText('Gaming')).toBeInTheDocument();
+ });
+ });
+
+ it('adds a new feed', async () => {
+ (global.fetch as any)
+ .mockResolvedValueOnce({ ok: true, json: async () => [] }) // Initial load
+ .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) // Add feed
+ .mockResolvedValueOnce({ ok: true, json: async () => [{ _id: 3, title: 'New Feed', url: 'http://new.com/rss' }] }); // Refresh load
+
+ render();
+
+ // Wait for initial load to finish
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+
+ const input = screen.getByPlaceholderText('https://example.com/feed.xml');
+ const button = screen.getByText('Add Feed');
+
+ fireEvent.change(input, { target: { value: 'http://new.com/rss' } });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith('/api/feed/', expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ url: 'http://new.com/rss' }),
+ }));
+ });
+
+ // Wait for refresh
+ await waitFor(() => {
+ expect(screen.getByText('New Feed')).toBeInTheDocument();
+ });
+ });
+
+ it('deletes a feed', async () => {
+ const mockFeeds = [
+ { _id: 1, title: 'Tech News', url: 'http://tech.com/rss', category: 'tech' },
+ ];
+
+ (global.fetch as any)
+ .mockResolvedValueOnce({ ok: true, json: async () => mockFeeds }) // Initial load
+ .mockResolvedValueOnce({ ok: true }); // Delete
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ expect(screen.getByText('Tech News')).toBeInTheDocument();
+ });
+
+ const deleteBtn = screen.getByTitle('Delete Feed');
+ fireEvent.click(deleteBtn);
+
+ await waitFor(() => {
+ expect(global.confirm).toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith('/api/feed/1', expect.objectContaining({ method: 'DELETE' }));
+ expect(screen.queryByText('Tech News')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
new file mode 100644
index 0000000..def8ffe
--- /dev/null
+++ b/frontend/src/components/Settings.tsx
@@ -0,0 +1,121 @@
+import React, { useEffect, useState } from 'react';
+import type { Feed } from '../types';
+import './Settings.css';
+
+export default function Settings() {
+ const [feeds, setFeeds] = useState([]);
+ const [newFeedUrl, setNewFeedUrl] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchFeeds();
+ }, []);
+
+ const fetchFeeds = () => {
+ setLoading(true);
+ fetch('/api/feed/')
+ .then((res) => {
+ if (!res.ok) throw new Error('Failed to fetch feeds');
+ return res.json();
+ })
+ .then((data) => {
+ setFeeds(data);
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ };
+
+ const handleAddFeed = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newFeedUrl) return;
+
+ setLoading(true);
+ fetch('/api/feed/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: newFeedUrl }),
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error('Failed to add feed');
+ return res.json();
+ })
+ .then(() => {
+ setNewFeedUrl('');
+ fetchFeeds(); // Refresh list (or we could append if server returns full feed object)
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ };
+
+ const handleDeleteFeed = (id: number) => {
+ if (!globalThis.confirm('Are you sure you want to delete this feed?')) return;
+
+ setLoading(true);
+ fetch(`/api/feed/${id}`, {
+ method: 'DELETE',
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error('Failed to delete feed');
+ setFeeds(feeds.filter((f) => f._id !== id));
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ };
+
+ return (
+
+
Settings
+
+
+
Add New Feed
+
+ {error &&
{error}
}
+
+
+
+
Manage Feeds
+ {loading &&
Loading...
}
+
+
+
+ );
+}
--
cgit v1.2.3