From 0a99b7b2533c28c10ee97ec3c4ae6d18bff079ed Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Sun, 15 Feb 2026 13:48:45 -0800 Subject: Frontend: Fix additive filtering logic and preserve search query during navigation (NK-r8rs7m) --- frontend/src/Navigation.test.tsx | 112 +++++++++++++++++++++++++++++++++++ frontend/src/components/FeedList.tsx | 46 ++++++++++---- 2 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 frontend/src/Navigation.test.tsx (limited to 'frontend') diff --git a/frontend/src/Navigation.test.tsx b/frontend/src/Navigation.test.tsx new file mode 100644 index 0000000..7d73249 --- /dev/null +++ b/frontend/src/Navigation.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import App from './App'; +import '@testing-library/jest-dom'; + +describe('Navigation and Filtering', () => { + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + // Default mock response for auth + vi.mocked(global.fetch).mockImplementation((url) => { + const urlStr = url.toString(); + if (urlStr.includes('/api/auth')) return Promise.resolve({ ok: true, json: async () => ({ status: 'ok' }) } as Response); + if (urlStr.includes('/api/feed/')) return Promise.resolve({ + ok: true, + json: async () => [ + { _id: 1, title: 'Feed 1', url: 'http://f1.com' }, + { _id: 2, title: 'Feed 2', url: 'http://f2.com' } + ] + } as Response); + if (urlStr.includes('/api/tag')) return Promise.resolve({ ok: true, json: async () => [] } as Response); + if (urlStr.includes('/api/stream')) return Promise.resolve({ ok: true, json: async () => [] } as Response); + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }); + }); + + it('preserves "all" filter when clicking a feed', async () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 }); + window.history.pushState({}, '', '/'); + render(); + + // Wait for sidebar to load and feeds section to be visible + await waitFor(() => { + expect(screen.queryByText(/Loading feeds/i)).not.toBeInTheDocument(); + }); + + // Expand feeds if not expanded + const feedsHeader = await screen.findByRole('heading', { name: /Feeds/i, level: 4 }); + fireEvent.click(feedsHeader); + + await waitFor(() => { + expect(screen.getByText('Feed 1')).toBeInTheDocument(); + }); + // Click 'all' filter + const allFilter = screen.getByText('all'); + fireEvent.click(allFilter); + + // Verify URL has filter=all + expect(window.location.search).toContain('filter=all'); + + // Click Feed 1 + const feed1Link = screen.getByText('Feed 1'); + fireEvent.click(feed1Link); + + // Verify URL is /feed/1?filter=all (or similar) + await waitFor(() => { + expect(window.location.pathname).toContain('/feed/1'); + expect(window.location.search).toContain('filter=all'); + }); + + // Click Feed 2 + const feed2Link = screen.getByText('Feed 2'); + fireEvent.click(feed2Link); + + // Verify URL is /feed/2?filter=all + await waitFor(() => { + expect(window.location.pathname).toContain('/feed/2'); + expect(window.location.search).toContain('filter=all'); + }); + }); + + it('highlights the correct filter link', async () => { + window.history.pushState({}, '', '/'); + render(); + + await waitFor(() => { + expect(screen.getByText('unread')).toHaveClass('active'); + }); + + fireEvent.click(screen.getByText('all')); + await waitFor(() => { + expect(screen.getByText('all')).toHaveClass('active'); + expect(screen.getByText('unread')).not.toHaveClass('active'); + }); + }); + + it('highlights "unread" as active even when on a feed page without filter param', async () => { + window.history.pushState({}, '', '/feed/1'); + render(); + + await waitFor(() => { + expect(screen.getByText('unread')).toHaveClass('active'); + }); + }); + + it('preserves search query when clicking a feed', async () => { + window.history.pushState({}, '', '/?q=linux'); + render(); + + await screen.findByRole('heading', { name: /Feeds/i, level: 4 }); + fireEvent.click(screen.getByRole('heading', { name: /Feeds/i, level: 4 })); + + await screen.findByText('Feed 1'); + fireEvent.click(screen.getByText('Feed 1')); + + await waitFor(() => { + expect(window.location.pathname).toContain('/feed/1'); + expect(window.location.search).toContain('q=linux'); + }); + }); +}); diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index 1ab5246..4e6738e 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Link, useNavigate, useSearchParams, useLocation, useParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams, useLocation, useMatch } from 'react-router-dom'; import type { Feed, Category } from '../types'; import './FeedList.css'; import './FeedListVariants.css'; @@ -26,7 +26,13 @@ export default function FeedList({ const navigate = useNavigate(); const [searchParams] = useSearchParams(); const location = useLocation(); - const { feedId, tagName } = useParams(); + const feedMatch = useMatch('/feed/:feedId'); + const tagMatch = useMatch('/tag/:tagName'); + const isRoot = useMatch('/'); + const isStreamPage = !!(isRoot || feedMatch || tagMatch); + + const feedId = feedMatch?.params.feedId; + const tagName = tagMatch?.params.tagName; const sidebarVariant = searchParams.get('sidebar') || localStorage.getItem('neko-sidebar-variant') || 'glass'; @@ -37,15 +43,31 @@ export default function FeedList({ } }, [searchParams]); - const currentFilter = - searchParams.get('filter') || - (location.pathname === '/' && !feedId && !tagName ? 'unread' : ''); + const currentFilter = searchParams.get('filter') || (isStreamPage ? 'unread' : ''); + + const getFilterLink = (filter: string) => { + const baseStreamPath = isStreamPage ? location.pathname : '/'; + const params = new URLSearchParams(searchParams); + params.set('filter', filter); + return `${baseStreamPath}?${params.toString()}`; + }; + + const getNavPath = (path: string) => { + const params = new URLSearchParams(searchParams); + if (!params.has('filter') && currentFilter) { + params.set('filter', currentFilter); + } + const qs = params.toString(); + return `${path}${qs ? '?' + qs : ''}`; + }; const handleSearch = (e: React.FormEvent) => { e.preventDefault(); if (searchQuery.trim()) { - const filterPart = currentFilter ? `&filter=${currentFilter}` : ''; - navigate(`/?q=${encodeURIComponent(searchQuery.trim())}${filterPart}`); + const params = new URLSearchParams(searchParams); + params.set('q', searchQuery.trim()); + if (currentFilter) params.set('filter', currentFilter); + navigate(`/?${params.toString()}`); } }; @@ -114,7 +136,7 @@ export default function FeedList({
  • @@ -123,7 +145,7 @@ export default function FeedList({
  • @@ -132,7 +154,7 @@ export default function FeedList({
  • @@ -151,7 +173,7 @@ export default function FeedList({ {tags.map((tag) => (
  • @@ -175,7 +197,7 @@ export default function FeedList({ {feeds.map((feed) => (
  • -- cgit v1.2.3