Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions app/routes/games.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
Center,
Spinner,
Presence,
Input,
InputGroup,
NativeSelect,
} from '@chakra-ui/react';
import { useCallback, useEffect, useState } from 'react';
import {
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -102,6 +123,8 @@ export const loader = async ({ request }) => {
const data = {
tags,
games: await Promise.all(games.map(computeGame)),
q: q ?? '',
sort,
};

return data;
Expand All @@ -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();

Expand Down Expand Up @@ -193,6 +239,31 @@ const Games = () => {

return (
<Box p={5} ref={divHeight}>
<Flex gap={3} mb={5} wrap="wrap" align="center">
<InputGroup startElement={<LuSearch />} maxW="320px" flex="1 1 220px">
<Input
type="search"
aria-label="Search games"
placeholder="Search games"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
<NativeSelect.Root size="md" width="auto">
<NativeSelect.Field
aria-label="Sort by"
value={sort}
onChange={(event) => updateParams({ sort: event.target.value === 'updated' ? '' : event.target.value })}
>
{Object.entries(SORTS).map(([value, { label }]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Flex>
<Wrap gap={2} mb={10} align="flex-end" role="group" aria-label="Filter by tag">
{tags.slice(0, 30).map((tag) => {
const selected = selectedTags.includes(tag.name);
Expand All @@ -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],
Expand Down
37 changes: 37 additions & 0 deletions e2e/games.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })));
});
Loading