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/components/Settings.css | 83 ++++++++++++++++++++ frontend/src/components/Settings.test.tsx | 92 +++++++++++++++++++++++ frontend/src/components/Settings.tsx | 121 ++++++++++++++++++++++++++++++ 3 files changed, 296 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/components') 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

+
+ setNewFeedUrl(e.target.value)} + placeholder="https://example.com/feed.xml" + required + className="feed-input" + disabled={loading} + /> + +
+ {error &&

{error}

} +
+ +
+

Manage Feeds

+ {loading &&

Loading...

} +
    + {feeds.map((feed) => ( +
  • +
    + {feed.title || '(No Title)'} + {feed.url} +
    + +
  • + ))} +
+
+
+ ); +} -- cgit v1.2.3