From bf7d4d5a0574e02b40cee787d39b9e4c0703040b Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Wed, 11 Nov 2026 10:26:56 +0100 Subject: [PATCH] Added persistent search and filters in the URL and show active filter chips. (#83) --- T04-Project-2/frontend/src/App.tsx | 22 ++++--- T04-Project-2/frontend/src/api/searchUrl.ts | 44 +++++++++++++ .../frontend/src/components/ActiveFilters.css | 31 +++++++++ .../frontend/src/components/ActiveFilters.tsx | 35 ++++++++++ .../frontend/src/components/FilterMenu.tsx | 3 +- .../frontend/src/components/GameCard.tsx | 5 +- .../src/components/GameDetailPage.tsx | 9 +-- .../frontend/src/components/SearchResults.css | 44 +++++++++++-- .../frontend/src/components/SearchResults.tsx | 2 + .../tests/components/SearchResults.test.tsx | 5 +- .../tests/components/SearchUrl.test.tsx | 16 +++++ .../tests/integration/journey.spec.ts | 65 ++++++++++++++++++- 12 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 T04-Project-2/frontend/src/api/searchUrl.ts create mode 100644 T04-Project-2/frontend/src/components/ActiveFilters.css create mode 100644 T04-Project-2/frontend/src/components/ActiveFilters.tsx create mode 100644 T04-Project-2/frontend/tests/components/SearchUrl.test.tsx diff --git a/T04-Project-2/frontend/src/App.tsx b/T04-Project-2/frontend/src/App.tsx index 00e56d1..9eb1dd3 100644 --- a/T04-Project-2/frontend/src/App.tsx +++ b/T04-Project-2/frontend/src/App.tsx @@ -10,7 +10,8 @@ import LoginPage from './components/LoginPage' import FavoritesPage from './components/FavoritesPage' import { FavoritesProvider } from './components/FavoritesProvider' import { gameQueryOptions } from './api/games' -import { DEFAULT_FILTER_VALUES, toSearchOptions } from './api/filters' +import { toSearchOptions, type FilterValues } from './api/filters' +import { readSearchUrl, writeSearchUrl } from './api/searchUrl' import type { AuthenticatedUser } from './api/auth' const AUTHENTICATED_USER_STORAGE_KEY = 'gaminggoat:authenticated-user'; @@ -44,7 +45,6 @@ function readAuthenticatedUser(): AuthenticatedUser | null { } function App() { - const [query, setQuery] = useState(''); const [authenticatedUser, setAuthenticatedUser] = useState(readAuthenticatedUser); const [accountNotice, setAccountNotice] = useState(null); const [isAccountNoticeFading, setIsAccountNoticeFading] = useState(false); @@ -56,6 +56,7 @@ function App() { ? { ...location, pathname: '/', search: '', hash: '' } : location); + const { query, filters } = readSearchUrl(location.search); const normalizedQuery = query.trim().toLowerCase(); const [debouncedQuery, setDebouncedQuery] = useState(normalizedQuery); @@ -90,7 +91,6 @@ function App() { } }, [authenticatedUser]); - const [filters, setFilters] = useState(DEFAULT_FILTER_VALUES); const search = useInfiniteQuery({ ...gameQueryOptions.infiniteSearch(debouncedQuery, toSearchOptions(filters)), @@ -98,10 +98,16 @@ function App() { }); const handleQueryChange = (nextQuery: string) => { - setQuery(nextQuery); - if (location.pathname !== '/' && nextQuery.trim()) { - navigate('/'); - } + const searchParams = writeSearchUrl(location.search, nextQuery, filters); + const isNewSearch = !query.trim() && Boolean(nextQuery.trim()); + navigate({ pathname: '/', search: searchParams ? `?${searchParams}` : '' }, { + replace: location.pathname === '/' && !isNewSearch, + }); + }; + + const handleApplyFilters = (nextFilters: FilterValues) => { + const searchParams = writeSearchUrl(location.search, query, nextFilters); + navigate({ pathname: '/', search: searchParams ? `?${searchParams}` : '' }); }; const closeLogin = () => { @@ -144,7 +150,7 @@ function App() { isLoading={normalizedQuery !== debouncedQuery || search.isLoading} error={search.isLoadingError ? search.error : null} filters={filters} - onApplyFilters={setFilters} + onApplyFilters={handleApplyFilters} hasNextPage={search.hasNextPage} isFetchingNextPage={search.isFetchingNextPage} nextPageError={search.isFetchNextPageError ? search.error : null} diff --git a/T04-Project-2/frontend/src/api/searchUrl.ts b/T04-Project-2/frontend/src/api/searchUrl.ts new file mode 100644 index 0000000..8d1a2b1 --- /dev/null +++ b/T04-Project-2/frontend/src/api/searchUrl.ts @@ -0,0 +1,44 @@ +import { DEFAULT_FILTER_VALUES, MAX_YEAR, MIN_YEAR, type FilterValues, type SortField } from './filters' + +const keys = ['q', 'genre', 'platform', 'rating', 'from', 'to', 'playtime', 'sort', 'order'] +const sorts: SortField[] = ['rating', 'released', 'playtime'] + +function numberParam(params: URLSearchParams, key: string, fallback: number, min: number, max: number, integer = false): number { + const raw = params.get(key) + if (!raw || !/^\d+(?:\.\d+)?$/.test(raw)) return fallback + const value = Number(raw) + return Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value)) ? value : fallback +} + +export function readSearchUrl(search: string): { query: string; filters: FilterValues } { + const params = new URLSearchParams(search) + const ids = (key: string) => [...new Set(params.getAll(key).filter(id => /^[1-9]\d*$/.test(id)))] + const sort = params.get('sort') + const from = numberParam(params, 'from', MIN_YEAR, MIN_YEAR, MAX_YEAR, true) + const to = numberParam(params, 'to', MAX_YEAR, MIN_YEAR, MAX_YEAR, true) + const sortBy = sorts.find(field => field === sort) ?? null + return { + query: params.get('q') ?? '', + filters: { + genres: ids('genre'), platforms: ids('platform'), + minRating: numberParam(params, 'rating', 0, 0, 5), + yearFrom: from <= to ? from : MIN_YEAR, yearTo: from <= to ? to : MAX_YEAR, + minPlaytime: numberParam(params, 'playtime', 0, 0, 100, true), + sortBy, sortOrder: sortBy && params.get('order') === 'ASC' ? 'ASC' : 'DESC', + }, + } +} + +export function writeSearchUrl(search: string, query: string, values: FilterValues): string { + const params = new URLSearchParams(search) + for (const key of keys) params.delete(key) + if (query.trim()) params.set('q', query) + values.genres.forEach(id => params.append('genre', id)) + values.platforms.forEach(id => params.append('platform', id)) + if (values.minRating > 0) params.set('rating', String(values.minRating)) + if (values.yearFrom !== DEFAULT_FILTER_VALUES.yearFrom) params.set('from', String(values.yearFrom)) + if (values.yearTo !== DEFAULT_FILTER_VALUES.yearTo) params.set('to', String(values.yearTo)) + if (values.minPlaytime > 0) params.set('playtime', String(values.minPlaytime)) + if (values.sortBy) { params.set('sort', values.sortBy); params.set('order', values.sortOrder) } + return params.toString() +} diff --git a/T04-Project-2/frontend/src/components/ActiveFilters.css b/T04-Project-2/frontend/src/components/ActiveFilters.css new file mode 100644 index 0000000..2fe4ef5 --- /dev/null +++ b/T04-Project-2/frontend/src/components/ActiveFilters.css @@ -0,0 +1,31 @@ +.active-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0; + min-width: 0; + max-width: 100%; +} + +.active-filter-chip { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0.7rem; + border: 1px solid rgba(25, 210, 74, 0.4); + border-radius: 999px; + background: rgba(25, 210, 74, 0.1); + color: var(--text); + font: inherit; + font-size: 0.8rem; + cursor: pointer; + min-height: 2.5rem; + max-width: 100%; + text-align: left; +} + +.active-filter-chip:hover, .active-filter-chip:focus-visible { + background: rgba(25, 210, 74, 0.2); + outline-color: var(--primary-color); +} diff --git a/T04-Project-2/frontend/src/components/ActiveFilters.tsx b/T04-Project-2/frontend/src/components/ActiveFilters.tsx new file mode 100644 index 0000000..65160da --- /dev/null +++ b/T04-Project-2/frontend/src/components/ActiveFilters.tsx @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query' +import { XIcon } from '@phosphor-icons/react' +import { DEFAULT_FILTER_VALUES, filterQueryOptions, type FilterValues } from '../api/filters' +import './ActiveFilters.css' + +type Props = { filters: FilterValues; onChange: (filters: FilterValues) => void } + +export function ActiveFilters({ filters, onChange }: Props) { + const { data: genres = [] } = useQuery({ ...filterQueryOptions.genres(), enabled: filters.genres.length > 0 }) + const { data: platforms = [] } = useQuery({ ...filterQueryOptions.platforms(), enabled: filters.platforms.length > 0 }) + const chips: { key: string; label: string; remove: () => void }[] = [] + for (const id of filters.genres) { + const name = genres.find(genre => genre.id === id)?.name ?? `Genre ${id}` + chips.push({ key: `genre-${id}`, label: name, remove: () => onChange({ ...filters, genres: filters.genres.filter(value => value !== id) }) }) + } + for (const id of filters.platforms) { + const name = platforms.find(platform => platform.id === id)?.name ?? `Platform ${id}` + chips.push({ key: `platform-${id}`, label: name, remove: () => onChange({ ...filters, platforms: filters.platforms.filter(value => value !== id) }) }) + } + if (filters.minRating > 0) chips.push({ key: 'rating', label: `Rating ${filters.minRating}+`, remove: () => onChange({ ...filters, minRating: 0 }) }) + if (filters.yearFrom !== DEFAULT_FILTER_VALUES.yearFrom) chips.push({ key: 'from', label: `From ${filters.yearFrom}`, remove: () => onChange({ ...filters, yearFrom: DEFAULT_FILTER_VALUES.yearFrom }) }) + if (filters.yearTo !== DEFAULT_FILTER_VALUES.yearTo) chips.push({ key: 'to', label: `Until ${filters.yearTo}`, remove: () => onChange({ ...filters, yearTo: DEFAULT_FILTER_VALUES.yearTo }) }) + if (filters.minPlaytime > 0) chips.push({ key: 'playtime', label: `Playtime ${filters.minPlaytime}+ hours`, remove: () => onChange({ ...filters, minPlaytime: 0 }) }) + if (filters.sortBy) chips.push({ key: 'sort', label: `Sort: ${filters.sortBy === 'released' ? 'release year' : filters.sortBy} (${filters.sortOrder === 'ASC' ? 'ascending' : 'descending'})`, remove: () => onChange({ ...filters, sortBy: null, sortOrder: 'DESC' }) }) + if (!chips.length) return null + return ( +
+ {chips.map(chip => ( + + ))} +
+ ) +} diff --git a/T04-Project-2/frontend/src/components/FilterMenu.tsx b/T04-Project-2/frontend/src/components/FilterMenu.tsx index 069a70c..a12227a 100644 --- a/T04-Project-2/frontend/src/components/FilterMenu.tsx +++ b/T04-Project-2/frontend/src/components/FilterMenu.tsx @@ -24,7 +24,8 @@ export const FilterMenu = ({ filters, onApply }: FilterMenuProps) => { const buttonRef = useRef(null) function toggleMenu() { - setVisible((prev) => prev ? false : true) + if (!visible) setDraft(filters) + setVisible((prev) => !prev) } function updateDraft(changes: Partial) { diff --git a/T04-Project-2/frontend/src/components/GameCard.tsx b/T04-Project-2/frontend/src/components/GameCard.tsx index 2a98466..f087c5b 100644 --- a/T04-Project-2/frontend/src/components/GameCard.tsx +++ b/T04-Project-2/frontend/src/components/GameCard.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import { Star } from '@phosphor-icons/react' import { useFavorites } from './useFavorites' import './GameCard.css' @@ -11,6 +11,7 @@ type GameCardProps = { } export const GameCard = ({ id, title, image, releaseDate }: GameCardProps) => { + const location = useLocation(); const { userId, favoriteIds, toggleFavorite } = useFavorites(); const databaseId = id && /^\d+$/.test(id) ? id : null; const isFavorite = Boolean(databaseId && favoriteIds.includes(databaseId)); @@ -30,7 +31,7 @@ export const GameCard = ({ id, title, image, releaseDate }: GameCardProps) => { return (
{databaseId ? ( - + {card} ) : card} diff --git a/T04-Project-2/frontend/src/components/GameDetailPage.tsx b/T04-Project-2/frontend/src/components/GameDetailPage.tsx index 95e50be..5ae246c 100644 --- a/T04-Project-2/frontend/src/components/GameDetailPage.tsx +++ b/T04-Project-2/frontend/src/components/GameDetailPage.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState, type FormEvent } from 'react' import { ArrowLeft, CalendarBlank, Star, X } from '@phosphor-icons/react' import DOMPurify from 'dompurify' -import { Link, useParams } from 'react-router-dom' +import { Link, useLocation, useParams } from 'react-router-dom' import type { AuthenticatedUser } from '../api/auth' import { useFavorites } from './useFavorites' import { createGameReview, deleteGameReview, gameFavoriteQueryOptions, gameQueryOptions, gameReviewQueryOptions } from '../api/games' @@ -14,6 +14,7 @@ type GameDetailPageProps = { function GameDetailPage({ authenticatedUser }: GameDetailPageProps) { const { gameId } = useParams(); + const location = useLocation(); const { userId, favoriteIds, toggleFavorite } = useFavorites(); const isDatabaseId = Boolean(gameId && /^\d+$/.test(gameId)); const { data: game, isPending, error } = useQuery({ @@ -79,7 +80,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) { return (

Game not found

- Back to games + Back to games
); } @@ -96,7 +97,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) { return (

Game not found

- Back to games + Back to games
); } @@ -116,7 +117,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) { }} >
- +