aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-15 13:48:45 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-15 13:48:45 -0800
commit0a99b7b2533c28c10ee97ec3c4ae6d18bff079ed (patch)
treeb23162328c13b38d7189cd596189c87a94a18be4
parent2327f93098b4278c93055a96224ae82cef60b083 (diff)
downloadneko-0a99b7b2533c28c10ee97ec3c4ae6d18bff079ed.tar.gz
neko-0a99b7b2533c28c10ee97ec3c4ae6d18bff079ed.tar.bz2
neko-0a99b7b2533c28c10ee97ec3c4ae6d18bff079ed.zip
Frontend: Fix additive filtering logic and preserve search query during navigation (NK-r8rs7m)
-rw-r--r--frontend/src/Navigation.test.tsx112
-rw-r--r--frontend/src/components/FeedList.tsx46
2 files changed, 146 insertions, 12 deletions
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(<App />);
+
+ // 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(<App />);
+
+ 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(<App />);
+
+ await waitFor(() => {
+ expect(screen.getByText('unread')).toHaveClass('active');
+ });
+ });
+
+ it('preserves search query when clicking a feed', async () => {
+ window.history.pushState({}, '', '/?q=linux');
+ render(<App />);
+
+ 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({
<ul className="filter-list">
<li className="unread_filter">
<Link
- to={location.pathname + '?filter=unread'}
+ to={getFilterLink('unread')}
className={currentFilter === 'unread' ? 'active' : ''}
onClick={handleLinkClick}
>
@@ -123,7 +145,7 @@ export default function FeedList({
</li>
<li className="all_filter">
<Link
- to={location.pathname + '?filter=all'}
+ to={getFilterLink('all')}
className={currentFilter === 'all' ? 'active' : ''}
onClick={handleLinkClick}
>
@@ -132,7 +154,7 @@ export default function FeedList({
</li>
<li className="starred_filter">
<Link
- to={location.pathname + '?filter=starred'}
+ to={getFilterLink('starred')}
className={currentFilter === 'starred' ? 'active' : ''}
onClick={handleLinkClick}
>
@@ -151,7 +173,7 @@ export default function FeedList({
{tags.map((tag) => (
<li key={tag.title} className="tag-item">
<Link
- to={`/tag/${encodeURIComponent(tag.title)}${currentFilter ? `?filter=${currentFilter}` : ''}`}
+ to={getNavPath(`/tag/${encodeURIComponent(tag.title)}`)}
className={`tag-link ${tagName === tag.title ? 'active' : ''}`}
onClick={handleLinkClick}
>
@@ -175,7 +197,7 @@ export default function FeedList({
{feeds.map((feed) => (
<li key={feed._id} className="sidebar-feed-item">
<Link
- to={`/feed/${feed._id}${currentFilter ? `?filter=${currentFilter}` : ''}`}
+ to={getNavPath(`/feed/${feed._id}`)}
className={`feed-title ${feedId === String(feed._id) ? 'active' : ''}`}
onClick={handleLinkClick}
>