From 40a2c336400e2285811ebd82c53d7bafa87e4a29 Mon Sep 17 00:00:00 2001 From: Christopher Date: Sun, 4 Oct 2026 10:24:11 +0200 Subject: [PATCH] Search and sort the games list /games could only be filtered by tag and always listed recently updated games first. Add a search box (?q=, debounced, matching name or description, case- insensitive) and a sort control (?sort= updated, newest or name; unknown values fall back to updated, and orderBy is picked from a whitelist). Both combine with the tag filters, which now keep the other parameters when toggled, and with infinite scroll; an id tiebreak keeps pages from overlapping. Fixes #206. Co-Authored-By: Claude Opus 5.5 --- app/routes/games.jsx | 79 +++++++++++++++++++++++++++++++++++++++++--- e2e/games.spec.js | 37 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/app/routes/games.jsx b/app/routes/games.jsx index 29eed103..4ccd123a 100644 --- a/app/routes/games.jsx +++ b/app/routes/games.jsx @@ -13,6 +13,9 @@ import { Center, Spinner, Presence, + Input, + InputGroup, + NativeSelect, } from '@chakra-ui/react'; import { useCallback, useEffect, useState } from 'react'; import { @@ -21,19 +24,36 @@ import { useLoaderData, useSearchParams, } from 'react-router'; -import { LuPlus } from 'react-icons/lu'; +import { LuPlus, LuSearch } from 'react-icons/lu'; import { db } from '../utils/db.server'; import computeGame from '../models/game'; import GameCard from '../components/GameCard'; +import useDebounce from '../hooks/useDebounce'; + +// The sort options: user input picks one by name, never reaches Prisma. +const SORTS = { + updated: { label: 'Recently updated', orderBy: { updated_at: 'desc' } }, + newest: { label: 'Recently added', orderBy: { created_at: 'desc' } }, + name: { label: 'Name (A–Z)', orderBy: { name: 'asc' } }, +}; +const DEFAULT_SORT = 'updated'; export const loader = async ({ request }) => { const { searchParams } = new URL(request.url); const page = Number(searchParams.get('page') || '1'); const selectedTags = searchParams.getAll('tags'); + const q = searchParams.get('q')?.trim() || null; + const sort = SORTS[searchParams.get('sort')] ? searchParams.get('sort') : DEFAULT_SORT; const where = { deleted: false, + ...(q && { + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { about: { contains: q, mode: 'insensitive' } }, + ], + }), ...(selectedTags.length > 0 && { AND: selectedTags.map((tag) => ({ game_tag: { @@ -76,7 +96,8 @@ export const loader = async ({ request }) => { }), db.game.findMany({ where, - orderBy: { updated_at: 'desc' }, + // id last, so pages don't overlap when sorted values tie. + orderBy: [SORTS[sort].orderBy, { id: 'asc' }], skip: (page - 1) * 10, take: 10, include: { @@ -102,6 +123,8 @@ export const loader = async ({ request }) => { const data = { tags, games: await Promise.all(games.map(computeGame)), + q: q ?? '', + sort, }; return data; @@ -114,11 +137,34 @@ export const meta = () => [ ]; const Games = () => { - const { games: initialGames, tags } = useLoaderData(); + const { games: initialGames, tags, q, sort } = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); const selectedTags = searchParams.getAll('tags'); + // Change some parameters and keep the others (search, sort, tags). + const updateParams = useCallback( + (changes, options) => + setSearchParams((params) => { + const next = new URLSearchParams(params); + next.delete('page'); + for (const [key, value] of Object.entries(changes)) { + next.delete(key); + for (const v of [].concat(value ?? [])) if (v !== '') next.append(key, v); + } + return next; + }, options), + [setSearchParams] + ); + + const [query, setQuery] = useState(q); + // Follow the URL when it changes elsewhere (back button, links). + useEffect(() => setQuery(q), [q]); + const debouncedQuery = useDebounce(query, 300); + useEffect(() => { + if (debouncedQuery.trim() !== q) updateParams({ q: debouncedQuery.trim() }, { replace: true }); + }, [debouncedQuery]); + const [games, setGames] = useState(initialGames); const fetcher = useFetcher(); @@ -193,6 +239,31 @@ const Games = () => { return ( + + } maxW="320px" flex="1 1 220px"> + setQuery(event.target.value)} + /> + + + updateParams({ sort: event.target.value === 'updated' ? '' : event.target.value })} + > + {Object.entries(SORTS).map(([value, { label }]) => ( + + ))} + + + + {tags.slice(0, 30).map((tag) => { const selected = selectedTags.includes(tag.name); @@ -215,7 +286,7 @@ const Games = () => { type="button" aria-pressed={selected} onClick={() => - setSearchParams({ + updateParams({ tags: selected ? selectedTags.filter((t) => t !== tag.name) : [...selectedTags, tag.name], diff --git a/e2e/games.spec.js b/e2e/games.spec.js index 2e4a6556..c6498fcf 100644 --- a/e2e/games.spec.js +++ b/e2e/games.spec.js @@ -42,3 +42,40 @@ test('the games tag filter works with the keyboard', async ({ page }) => { await expect(tag).toHaveAttribute('aria-pressed', 'false'); await expect(page).not.toHaveURL(/tags=/); }); + +// #206: the games list could only be filtered by tag. +test('the games list can be searched and sorted', async ({ page }) => { + await page.goto('/games?sort=name'); + const names = await page.locator('main h3 a[href^="/game/"]').allInnerTexts(); + expect(names.length).toBeGreaterThan(1); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))); + await expect(page.getByLabel('Sort by')).toHaveValue('name'); + + const target = names[1]; + const word = target.split(/\s+/).find((w) => w.length > 3) ?? target; + await page.getByRole('searchbox', { name: 'Search games' }).fill(word); + await expect(page).toHaveURL(new RegExp(`[?&]q=${encodeURIComponent(word)}`)); + await expect(page).toHaveURL(/[?&]sort=name/); + await expect(page.locator('main h3 a[href^="/game/"]', { hasText: target })).toBeVisible(); + for (const name of await page.locator('main h3 a[href^="/game/"]').allInnerTexts()) { + expect(name.toLowerCase()).toContain(word.toLowerCase()); + } + + // Tag filters keep the search and the sort. + await page.getByRole('group', { name: 'Filter by tag' }).getByRole('button').first().click(); + await expect(page).toHaveURL(/[?&]tags=/); + await expect(page).toHaveURL(/[?&]q=/); + await expect(page).toHaveURL(/[?&]sort=name/); + + // Unknown sorts fall back to the default. + expect((await page.request.get('/games?sort=bogus')).status()).toBe(200); + + // Loading more on scroll keeps the sort. + await page.goto('/games?sort=name'); + const cards = page.locator('main h3 a[href^="/game/"]'); + const firstPage = await cards.count(); + await page.mouse.wheel(0, 20000); + await expect.poll(() => cards.count()).toBeGreaterThan(firstPage); + const all = await cards.allInnerTexts(); + expect(all).toEqual([...all].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))); +});