diff --git a/client/package-lock.json b/client/package-lock.json
index d34d68b..fae9880 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.2.8",
- "react-dom": "^19.2.8"
+ "react-dom": "^19.2.8",
+ "zustand": "^5.0.15"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
@@ -1044,7 +1045,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1218,7 +1219,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -2404,6 +2405,35 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/client/package.json b/client/package.json
index fed851d..83ad944 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,7 +15,8 @@
},
"dependencies": {
"react": "^19.2.8",
- "react-dom": "^19.2.8"
+ "react-dom": "^19.2.8",
+ "zustand": "^5.0.15"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
diff --git a/client/src/api/places.test.ts b/client/src/api/places.test.ts
index debc63c..3d80b7f 100644
--- a/client/src/api/places.test.ts
+++ b/client/src/api/places.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { fetchAllPlaces, fetchPlaces } from './places'
+import { fetchAllPlaces, fetchPlaces, fetchVenueTypes } from './places'
const weekdays = [
'SUNDAY',
@@ -121,9 +121,72 @@ describe('GraphQL places API', () => {
expect(places).toHaveLength(101)
expect(places.at(-1)?.name).toBe('Siste sted')
- expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({
- offset: 100,
- limit: 100,
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toMatchObject(
+ { offset: 100, limit: 100 },
+ )
+ })
+
+ it('sends search, type filter and sort to the server', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({ data: { venues: { items: [], totalCount: 0 } } }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ await fetchPlaces(0, 10, {
+ search: ' pub ',
+ types: ['Pub', 'Nattklubb'],
+ sort: 'RATING',
+ })
+
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).variables).toEqual({
+ offset: 0,
+ limit: 10,
+ search: 'pub',
+ types: ['PUB', 'NIGHTCLUB'],
+ sort: 'RATING',
+ })
+ })
+
+ it('leaves out empty search and filters, and passes on an abort signal', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({ data: { venues: { items: [], totalCount: 0 } } }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const { signal } = new AbortController()
+
+ await fetchPlaces(0, 10, { search: ' ', types: [], sort: 'NAME' }, signal)
+
+ const [, options] = fetchMock.mock.calls[0]
+ expect(JSON.parse(options.body).variables).toMatchObject({
+ search: null,
+ types: null,
})
+ expect(options.signal).toBe(signal)
+ })
+
+ it('lists the venue types as the names shown in the app', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({ data: { venueTypes: ['PUB', 'BAR', 'UKJENT'] } }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ ),
+ )
+
+ await expect(fetchVenueTypes()).resolves.toEqual(['Pub', 'Bar'])
})
})
diff --git a/client/src/api/places.ts b/client/src/api/places.ts
index b11767a..611d246 100644
--- a/client/src/api/places.ts
+++ b/client/src/api/places.ts
@@ -1,11 +1,29 @@
-import type { Place, PlacesPage, VenueType } from '../types/place'
+import {
+ DEFAULT_PLACE_QUERY,
+ type Place,
+ type PlaceQuery,
+ type PlacesPage,
+ type VenueType,
+} from '../types/place'
const GRAPHQL_ENDPOINT = '/project2/api/graphql'
const MAX_PAGE_SIZE = 100
const VENUES_QUERY = `
- query Venues($offset: Int!, $limit: Int!) {
- venues(offset: $offset, limit: $limit) {
+ query Venues(
+ $offset: Int!
+ $limit: Int!
+ $search: String
+ $types: [VenueType!]
+ $sort: VenueSort
+ ) {
+ venues(
+ offset: $offset
+ limit: $limit
+ search: $search
+ types: $types
+ sort: $sort
+ ) {
totalCount
items {
id
@@ -32,6 +50,12 @@ const VENUES_QUERY = `
}
`
+const VENUE_TYPES_QUERY = `
+ query VenueTypes {
+ venueTypes
+ }
+`
+
type GraphqlOpeningHours = {
day: string
opens: string | null
@@ -50,16 +74,6 @@ type GraphqlVenue = Omit<
websiteUrl: string | null
}
-type VenuesResponse = {
- data?: {
- venues: {
- items: GraphqlVenue[]
- totalCount: number
- }
- }
- errors?: { message: string }[]
-}
-
const weekdayNames = [
'SUNDAY',
'MONDAY',
@@ -70,6 +84,13 @@ const weekdayNames = [
'SATURDAY',
]
+/** Stedstypene slik backend skriver dem, og slik de vises i appen. */
+const VENUE_TYPE_LABELS: Record = {
+ BAR: 'Bar',
+ PUB: 'Pub',
+ NIGHTCLUB: 'Nattklubb',
+}
+
function formatOpeningHours(hours: GraphqlOpeningHours[], now = new Date()) {
const today = hours.filter(({ day }) => day === weekdayNames[now.getDay()])
if (!today.length) return 'Åpningstider ikke oppgitt'
@@ -82,16 +103,20 @@ function formatOpeningHours(hours: GraphqlOpeningHours[], now = new Date()) {
}
function toVenueType(type: string): VenueType {
- const venueTypes: Record = {
- BAR: 'Bar',
- PUB: 'Pub',
- NIGHTCLUB: 'Nattklubb',
- }
- const venueType = venueTypes[type]
+ const venueType = VENUE_TYPE_LABELS[type]
if (!venueType) throw new Error(`Ukjent stedstype fra GraphQL: ${type}`)
return venueType
}
+/** Motsatt vei: fra visningsnavnet til verdien backend forventer. */
+function toGraphqlType(venueType: VenueType): string {
+ const entry = Object.entries(VENUE_TYPE_LABELS).find(
+ ([, label]) => label === venueType,
+ )
+ if (!entry) throw new Error(`Stedstypen finnes ikke i backend: ${venueType}`)
+ return entry[0]
+}
+
function toPlace(venue: GraphqlVenue): Place {
return {
...venue,
@@ -103,36 +128,74 @@ function toPlace(venue: GraphqlVenue): Place {
}
}
-/** Henter én avgrenset side med utesteder fra vår GraphQL-backend. */
-export async function fetchPlaces(
- offset: number,
- limit: number,
-): Promise {
+/** Sender én GraphQL-spørring og returnerer `data`, eller kaster en feil. */
+async function requestGraphql(
+ query: string,
+ variables: Record,
+ signal?: AbortSignal,
+): Promise {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- query: VENUES_QUERY,
- variables: { offset, limit },
- }),
+ body: JSON.stringify({ query, variables }),
+ signal,
})
if (!response.ok) {
throw new Error(`GraphQL svarte med HTTP ${response.status}.`)
}
- const payload = (await response.json()) as VenuesResponse
+ const payload = (await response.json()) as {
+ data?: T
+ errors?: { message: string }[]
+ }
if (payload.errors?.length) {
throw new Error(payload.errors[0].message)
}
if (!payload.data) throw new Error('GraphQL-responsen mangler data.')
+ return payload.data
+}
+
+/**
+ * Henter én avgrenset side med utesteder. Søk, filter og sortering gjøres av
+ * databasen, så klienten aldri trenger å laste hele katalogen.
+ */
+export async function fetchPlaces(
+ offset: number,
+ limit: number,
+ query: PlaceQuery = DEFAULT_PLACE_QUERY,
+ signal?: AbortSignal,
+): Promise {
+ const data = await requestGraphql<{
+ venues: { items: GraphqlVenue[]; totalCount: number }
+ }>(
+ VENUES_QUERY,
+ {
+ offset,
+ limit,
+ search: query.search.trim() || null,
+ types: query.types.length ? query.types.map(toGraphqlType) : null,
+ sort: query.sort,
+ },
+ signal,
+ )
+
return {
- items: payload.data.venues.items.map(toPlace),
- totalCount: payload.data.venues.totalCount,
+ items: data.venues.items.map(toPlace),
+ totalCount: data.venues.totalCount,
}
}
+/** Henter stedstypene som finnes i databasen, til filtervalgene. */
+export async function fetchVenueTypes(): Promise {
+ const data = await requestGraphql<{ venueTypes: string[] }>(
+ VENUE_TYPES_QUERY,
+ {},
+ )
+ return data.venueTypes.flatMap((type) => VENUE_TYPE_LABELS[type] ?? [])
+}
+
/** Henter alle sider til visninger som foreløpig trenger hele datasettet. */
export async function fetchAllPlaces(): Promise {
const firstPage = await fetchPlaces(0, MAX_PAGE_SIZE)
diff --git a/client/src/components/PlaceFilter.tsx b/client/src/components/PlaceFilter.tsx
index 534d996..ecb7712 100644
--- a/client/src/components/PlaceFilter.tsx
+++ b/client/src/components/PlaceFilter.tsx
@@ -1,26 +1,37 @@
+import type { PlaceSort, VenueType } from '../types/place'
+
type PlaceFilterProps = {
query: string
/** Filtrene som er valgt, i den rekkefølgen de ble lagt til. */
- filters: string[]
+ filters: VenueType[]
/** Filtrene som kan legges til. */
- options: string[]
+ options: VenueType[]
+ sort: PlaceSort
onQueryChange: (query: string) => void
- onAddFilter: (filter: string) => void
- onRemoveFilter: (filter: string) => void
+ onAddFilter: (filter: VenueType) => void
+ onRemoveFilter: (filter: VenueType) => void
+ onSortChange: (sort: PlaceSort) => void
onReset: () => void
}
-/** Søk og filter øverst på hovedsiden. Valgte filter vises som merkelapper. */
+const sortOptions: { value: PlaceSort; label: string }[] = [
+ { value: 'NAME', label: 'Navn (A–Å)' },
+ { value: 'RATING', label: 'Høyest vurdert' },
+]
+
+/** Søk, filter og sortering øverst på hovedsiden. Valgte filter vises som merkelapper. */
export function PlaceFilter({
query,
filters,
options,
+ sort,
onQueryChange,
onAddFilter,
onRemoveFilter,
+ onSortChange,
onReset,
}: PlaceFilterProps) {
- const isActive = query !== '' || filters.length > 0
+ const isActive = query !== '' || filters.length > 0 || sort !== 'NAME'
return (
@@ -45,7 +56,7 @@ export function PlaceFilter({
diff --git a/client/src/hooks/useDebouncedValue.test.ts b/client/src/hooks/useDebouncedValue.test.ts
new file mode 100644
index 0000000..e86c22e
--- /dev/null
+++ b/client/src/hooks/useDebouncedValue.test.ts
@@ -0,0 +1,24 @@
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useDebouncedValue } from './useDebouncedValue'
+
+describe('useDebouncedValue', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => vi.useRealTimers())
+
+ it('only passes on the latest value once it has been still for the delay', () => {
+ const { result, rerender } = renderHook(
+ ({ value }) => useDebouncedValue(value, 300),
+ { initialProps: { value: 'a' } },
+ )
+
+ rerender({ value: 'ab' })
+ act(() => vi.advanceTimersByTime(200))
+ rerender({ value: 'abc' })
+ act(() => vi.advanceTimersByTime(200))
+ expect(result.current).toBe('a')
+
+ act(() => vi.advanceTimersByTime(100))
+ expect(result.current).toBe('abc')
+ })
+})
diff --git a/client/src/hooks/useDebouncedValue.ts b/client/src/hooks/useDebouncedValue.ts
new file mode 100644
index 0000000..40de7f2
--- /dev/null
+++ b/client/src/hooks/useDebouncedValue.ts
@@ -0,0 +1,13 @@
+import { useEffect, useState } from 'react'
+
+/** Gir verdien først når den har stått i ro en stund, for eksempel et søkeord. */
+export function useDebouncedValue(value: T, delayMs: number): T {
+ const [debounced, setDebounced] = useState(value)
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delayMs)
+ return () => clearTimeout(timer)
+ }, [value, delayMs])
+
+ return debounced
+}
diff --git a/client/src/hooks/usePlaces.ts b/client/src/hooks/usePlaces.ts
index 9ec0639..119a072 100644
--- a/client/src/hooks/usePlaces.ts
+++ b/client/src/hooks/usePlaces.ts
@@ -1,69 +1,142 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchPlaces } from '../api/places'
-import type { Place, PlacesPage } from '../types/place'
+import type { Place, PlaceQuery, PlacesPage } from '../types/place'
/** Antall steder som hentes første gang og for hver trykk på «Vis mer». */
export const PAGE_SIZE = 10
-type Status = 'loading' | 'idle' | 'loadingMore' | 'error'
+/** Første side per spørring huskes, så siden er klar når man kommer tilbake. */
+const firstPages = new Map()
-/** Første side huskes, så hovedsiden er klar med en gang når man kommer tilbake. */
-let firstPage: PlacesPage | null = null
+/** Tømmer det som er husket. Brukes av tester. */
+export function clearPlacesCache() {
+ firstPages.clear()
+}
-/** Henter utesteder og lar brukeren laste flere. */
-export function usePlaces() {
- const [places, setPlaces] = useState(firstPage?.items ?? [])
- const [totalCount, setTotalCount] = useState(firstPage?.totalCount ?? 0)
- const [status, setStatus] = useState(firstPage ? 'idle' : 'loading')
+function cacheKey({ search, types, sort }: PlaceQuery) {
+ return JSON.stringify([search.trim(), [...types].sort(), sort])
+}
- useEffect(() => {
- if (firstPage) return
+/** Stedene vi har lastet for en spørring, og hvilken spørring de hører til. */
+type Result = {
+ key: string
+ places: Place[]
+ totalCount: number
+ /** Om spørringen hadde søk eller filter, når stedene ble hentet. */
+ isFiltered: boolean
+}
+
+const isFilteredQuery = ({ search, types }: PlaceQuery) =>
+ search.trim() !== '' || types.length > 0
+
+const NO_PLACES: Place[] = []
+/**
+ * Henter utesteder for en spørring og lar brukeren laste flere. Endres
+ * spørringen, startes det på nytt fra første side, og svar på en gammel
+ * spørring blir ignorert.
+ */
+export function usePlaces(query: PlaceQuery) {
+ const key = cacheKey(query)
+ // Er første side allerede husket, vises den med en gang.
+ const [result, setResult] = useState(() => {
+ const cached = firstPages.get(key)
+ return cached
+ ? {
+ key,
+ places: cached.items,
+ totalCount: cached.totalCount,
+ isFiltered: isFilteredQuery(query),
+ }
+ : null
+ })
+ const [failedKey, setFailedKey] = useState(null)
+ const [isLoadingMore, setIsLoadingMore] = useState(false)
+
+ useEffect(() => {
let ignore = false
+ const controller = new AbortController()
+ const cached = firstPages.get(key)
+
+ const request = cached
+ ? Promise.resolve(cached)
+ : fetchPlaces(0, PAGE_SIZE, query, controller.signal)
- fetchPlaces(0, PAGE_SIZE)
+ request
.then((page) => {
- firstPage = page
+ firstPages.set(key, page)
if (ignore) return
- setPlaces(page.items)
- setTotalCount(page.totalCount)
- setStatus('idle')
+ setResult({
+ key,
+ places: page.items,
+ totalCount: page.totalCount,
+ isFiltered: isFilteredQuery(query),
+ })
+ setFailedKey(null)
})
.catch(() => {
- if (!ignore) setStatus('error')
+ if (!ignore) setFailedKey(key)
})
return () => {
ignore = true
+ controller.abort()
}
- }, [])
+ }, [key, query])
+ // De forrige stedene blir stående til de nye er klare, så siden ikke blinker.
+ const places = result?.places ?? NO_PLACES
+ const totalCount = result?.totalCount ?? 0
const loadedCount = places.length
+ const status =
+ failedKey === key
+ ? 'error'
+ : result?.key !== key
+ ? 'loading'
+ : isLoadingMore
+ ? 'loadingMore'
+ : 'idle'
+
const loadMore = useCallback(async () => {
- setStatus('loadingMore')
+ setIsLoadingMore(true)
try {
- const page = await fetchPlaces(loadedCount, PAGE_SIZE)
- setPlaces((current) => [...current, ...page.items])
- setTotalCount(page.totalCount)
- setStatus('idle')
+ const page = await fetchPlaces(loadedCount, PAGE_SIZE, query)
+ // Har spørringen endret seg i mellomtiden, hører ikke svaret hjemme her.
+ setResult((current) =>
+ current?.key === key
+ ? {
+ ...current,
+ places: [...current.places, ...page.items],
+ totalCount: page.totalCount,
+ }
+ : current,
+ )
} catch {
- setStatus('error')
+ setFailedKey(key)
+ } finally {
+ setIsLoadingMore(false)
}
- }, [loadedCount])
+ }, [key, loadedCount, query])
/** Fjerner den sist lastede siden, men aldri under første side. */
const showLess = useCallback(() => {
- setPlaces((current) => {
- const pages = Math.ceil(current.length / PAGE_SIZE)
- return current.slice(0, Math.max(1, pages - 1) * PAGE_SIZE)
+ setResult((current) => {
+ if (!current) return current
+ const pages = Math.ceil(current.places.length / PAGE_SIZE)
+ return {
+ ...current,
+ places: current.places.slice(0, Math.max(1, pages - 1) * PAGE_SIZE),
+ }
})
}, [])
return {
places,
totalCount,
+ /** Gjelder stedene som vises nå, som kan være fra forrige spørring. */
+ isFiltered: result?.isFiltered ?? false,
status,
hasMore: loadedCount < totalCount,
canShowLess: loadedCount > PAGE_SIZE,
diff --git a/client/src/pages/HomePage.test.tsx b/client/src/pages/HomePage.test.tsx
index 459172f..d8178c6 100644
--- a/client/src/pages/HomePage.test.tsx
+++ b/client/src/pages/HomePage.test.tsx
@@ -1,138 +1,185 @@
-import { render, screen, within } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { clearPlacesCache } from '../hooks/usePlaces'
+import { resetCatalogStore } from '../stores/catalogStore'
import { makePlace } from '../test/places'
+import type { Place, PlaceQuery } from '../types/place'
import { HomePage } from './HomePage'
const mocks = vi.hoisted(() => ({
- loadMore: vi.fn(),
- showLess: vi.fn(),
+ fetchPlaces: vi.fn(),
}))
-const places = [
+const places: Place[] = [
makePlace(),
makePlace({
id: 'gloshaugen-bar',
name: 'Gløshaugen Bar',
type: 'Bar',
- websiteUrl: 'https://example.com/gloshaugen-bar',
+ rating: 4.9,
}),
+ ...Array.from({ length: 18 }, (_, index) =>
+ makePlace({
+ id: `pub-${index}`,
+ name: `Sted ${String(index).padStart(2, '0')}`,
+ rating: 3,
+ }),
+ ),
]
vi.mock('../api/places', () => ({
- fetchAllPlaces: () => Promise.resolve(places),
-}))
-
-vi.mock('../hooks/useCurrentUser', () => ({
- useCurrentUser: () => ({
- user: {
- id: 'u1',
- name: 'Test',
- avatarUrl: null,
- favouritePlaces: [places[1]],
- },
- status: 'ready',
- }),
+ fetchPlaces: (...args: unknown[]) => mocks.fetchPlaces(...args),
+ fetchVenueTypes: () => Promise.resolve(['Pub', 'Bar']),
}))
vi.mock('../hooks/useNow', () => ({
useNow: () => new Date(2026, 9, 10, 20, 0),
}))
-vi.mock('../hooks/usePlaces', () => ({
- PAGE_SIZE: 10,
- usePlaces: () => ({
- places,
- totalCount: 20,
- status: 'idle',
- hasMore: true,
- canShowLess: true,
- loadMore: mocks.loadMore,
- showLess: mocks.showLess,
- }),
-}))
+/** Gjør det databasen gjør: søker, filtrerer, sorterer og deler i sider. */
+function answerLikeTheServer(
+ offset: number,
+ limit: number,
+ { search, types, sort }: PlaceQuery,
+) {
+ const matches = places
+ .filter(
+ (place) =>
+ (types.length === 0 || types.includes(place.type)) &&
+ place.name.toLowerCase().includes(search.trim().toLowerCase()),
+ )
+ .sort((a, b) =>
+ sort === 'RATING' ? b.rating - a.rating : a.name.localeCompare(b.name),
+ )
+
+ return Promise.resolve({
+ items: matches.slice(offset, offset + limit),
+ totalCount: matches.length,
+ })
+}
+
+/** Spørringene som er sendt til serveren, i rekkefølge. */
+const requests = () =>
+ mocks.fetchPlaces.mock.calls.map(([offset, , query]) => ({
+ offset,
+ ...(query as PlaceQuery),
+ }))
describe('HomePage', () => {
- it('lets the user move between places and select a suggestion', async () => {
+ beforeEach(() => {
vi.spyOn(Math, 'random').mockReturnValue(0)
- const user = userEvent.setup()
+ mocks.fetchPlaces.mockReset()
+ mocks.fetchPlaces.mockImplementation(answerLikeTheServer)
+ clearPlacesCache()
+ resetCatalogStore()
+ })
+ it('lets the user move between places and select a suggestion', async () => {
+ const user = userEvent.setup()
render()
expect(
- screen.getByRole('heading', { name: 'Nidelven Pub' }),
+ await screen.findByRole('heading', { name: 'Gløshaugen Bar' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Neste' }))
expect(
- screen.getByRole('heading', { name: 'Gløshaugen Bar' }),
+ screen.getByRole('heading', { name: 'Nidelven Pub' }),
).toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: /Nidelven Pub/ }))
+ await user.click(screen.getByRole('button', { name: /Gløshaugen Bar/ }))
expect(
- screen.getByRole('heading', { name: 'Nidelven Pub' }),
+ screen.getByRole('heading', { name: 'Gløshaugen Bar' }),
).toBeInTheDocument()
})
- it('searches and filters all places and can be reset', async () => {
- vi.spyOn(Math, 'random').mockReturnValue(0)
+ it('sends one request for a typed search word, not one per key', async () => {
const user = userEvent.setup()
-
render()
+ await screen.findByRole('heading', { name: 'Gløshaugen Bar' })
await user.type(
screen.getByRole('searchbox', { name: 'Søk etter steder' }),
'gløshaugen',
)
- expect(
- await screen.findByRole('heading', { name: 'Gløshaugen Bar' }),
- ).toBeInTheDocument()
- expect(
- screen.queryByRole('button', { name: 'Vis mer' }),
- ).not.toBeInTheDocument()
- expect(screen.getByText('Viser 1 treff')).toBeInTheDocument()
- await user.clear(screen.getByRole('searchbox'))
- await user.selectOptions(screen.getByLabelText('Legg til filter'), 'Pub')
- expect(
- await screen.findByRole('heading', { name: 'Nidelven Pub' }),
- ).toBeInTheDocument()
+ expect(await screen.findByText('Viser 1 treff')).toBeInTheDocument()
+ const searches = requests().filter(({ search }) => search !== '')
+ expect(searches).toHaveLength(1)
+ expect(searches[0]).toMatchObject({ search: 'gløshaugen', offset: 0 })
+ })
+
+ it('filters by type and sorts on the server, and can be reset', async () => {
+ const user = userEvent.setup()
+ render()
+ await screen.findByRole('heading', { name: 'Gløshaugen Bar' })
await user.selectOptions(screen.getByLabelText('Legg til filter'), 'Bar')
+ expect(await screen.findByText('Viser 1 treff')).toBeInTheDocument()
+ expect(requests().at(-1)).toMatchObject({ types: ['Bar'] })
+
+ await user.selectOptions(screen.getByLabelText('Legg til filter'), 'Pub')
const chips = screen.getByRole('list', { name: 'Valgte filter' })
expect(within(chips).getAllByRole('listitem')).toHaveLength(2)
+ await waitFor(() =>
+ expect(requests().at(-1)).toMatchObject({ types: ['Bar', 'Pub'] }),
+ )
await user.selectOptions(
- screen.getByLabelText('Legg til filter'),
- 'Favoritter',
+ screen.getByLabelText('Sorter etter'),
+ 'Høyest vurdert',
+ )
+ await waitFor(() =>
+ expect(requests().at(-1)).toMatchObject({ sort: 'RATING' }),
)
- expect(
- await screen.findByRole('heading', { name: 'Gløshaugen Bar' }),
- ).toBeInTheDocument()
- expect(
- screen.queryAllByRole('button', { name: /Nidelven Pub/ }),
- ).toHaveLength(0)
await user.click(screen.getByRole('button', { name: 'Fjern filteret Bar' }))
- expect(
- screen.queryByRole('heading', { name: 'Gløshaugen Bar' }),
- ).not.toBeInTheDocument()
- expect(await screen.findByText('Ingen steder passer søket.')).toBeVisible()
+ await waitFor(() =>
+ expect(requests().at(-1)).toMatchObject({ types: ['Pub'] }),
+ )
await user.click(screen.getByRole('button', { name: 'Nullstill' }))
- expect(screen.getByRole('button', { name: 'Vis mer' })).toBeInTheDocument()
+ // Den første siden for et tomt søk er husket, så den vises uten nytt kall.
+ expect(await screen.findByText('Viser 10 av 20 steder')).toBeVisible()
+ expect(screen.getByLabelText('Sorter etter')).toHaveValue('NAME')
+ expect(screen.getByText('Ingen filter valgt')).toBeInTheDocument()
})
- it('forwards the list expansion actions', async () => {
- vi.spyOn(Math, 'random').mockReturnValue(0)
+ it('tells the user when nothing matches', async () => {
const user = userEvent.setup()
+ render()
+ await screen.findByRole('heading', { name: 'Gløshaugen Bar' })
+
+ await user.type(screen.getByRole('searchbox'), 'finnes ikke')
+
+ expect(
+ await screen.findByText('Ingen steder passer søket.'),
+ ).toBeInTheDocument()
+ })
+ it('loads the next page from the server and can show less again', async () => {
+ const user = userEvent.setup()
render()
+ expect(await screen.findByText('Viser 10 av 20 steder')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Vis mer' }))
+ expect(await screen.findByText('Viser 20 av 20 steder')).toBeVisible()
+ expect(requests().at(-1)).toMatchObject({ offset: 10 })
+ expect(
+ screen.queryByRole('button', { name: 'Vis mer' }),
+ ).not.toBeInTheDocument()
+
await user.click(screen.getByRole('button', { name: 'Vis mindre' }))
+ expect(screen.getByText('Viser 10 av 20 steder')).toBeVisible()
+ })
- expect(mocks.loadMore).toHaveBeenCalledOnce()
- expect(mocks.showLess).toHaveBeenCalledOnce()
+ it('shows an error when the server cannot be reached', async () => {
+ mocks.fetchPlaces.mockRejectedValue(new Error('Nede'))
+ render()
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Kunne ikke hente steder',
+ )
})
})
diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx
index 43e2e73..fe879ad 100644
--- a/client/src/pages/HomePage.tsx
+++ b/client/src/pages/HomePage.tsx
@@ -1,117 +1,102 @@
import { useMemo, useState } from 'react'
-import { fetchAllPlaces } from '../api/places'
+import { fetchVenueTypes } from '../api/places'
import { FeaturedPlace } from '../components/FeaturedPlace'
import { PlaceFilter } from '../components/PlaceFilter'
import { PlaceList } from '../components/PlaceList'
-import { useCurrentUser } from '../hooks/useCurrentUser'
+import { useDebouncedValue } from '../hooks/useDebouncedValue'
import { useFetch } from '../hooks/useFetch'
import { useNow } from '../hooks/useNow'
import { PAGE_SIZE, usePlaces } from '../hooks/usePlaces'
-import type { Place } from '../types/place'
-import { matchesQuery } from '../utils/placeSearch'
+import { useCatalogStore } from '../stores/catalogStore'
+import type { Place, PlaceQuery } from '../types/place'
-/** Filteret som bare viser brukerens favorittsteder. */
-const FAVOURITES = 'Favoritter'
+/** Så lenge må søkeordet stå i ro før det sendes til serveren. */
+const SEARCH_DEBOUNCE_MS = 300
export function HomePage() {
const now = useNow()
+ const {
+ search,
+ types,
+ sort,
+ selected,
+ setSearch,
+ addType,
+ removeType,
+ setSort,
+ select,
+ reset,
+ } = useCatalogStore()
+
+ // Trekkes én gang per sidelasting, slik at det fremhevede stedet er tilfeldig.
+ const [startFraction] = useState(Math.random)
+
+ // Søk, filter og sortering gjøres av serveren på hele katalogen.
+ const debouncedSearch = useDebouncedValue(search, SEARCH_DEBOUNCE_MS)
+ const query = useMemo(
+ () => ({ search: debouncedSearch, types, sort }),
+ [debouncedSearch, types, sort],
+ )
const {
places,
totalCount,
+ isFiltered,
status,
hasMore,
canShowLess,
loadMore,
showLess,
- } = usePlaces()
-
- // Trekkes én gang per sidelasting, slik at det fremhevede stedet er tilfeldig.
- const [startFraction] = useState(Math.random)
- // Lagrer hele stedet, så det blir stående selv om «Vis mindre» fjerner det
- // fra listen.
- const [selected, setSelected] = useState(null)
-
- const [query, setQuery] = useState('')
- const [filters, setFilters] = useState([])
- const { user } = useCurrentUser()
-
- // Søk og filter gjelder alle stedene, ikke bare de som er lastet i listen.
- const { data: allPlaces, status: allStatus } = useFetch(fetchAllPlaces)
- const types = useMemo(
- () => [...new Set((allPlaces ?? []).map((place) => place.type))],
- [allPlaces],
- )
+ } = usePlaces(query)
- // Filter av ulik sort kombineres: stedet må passe med alle sortene, men bare
- // en av typene (Pub og Vinbar gir steder som er det ene eller det andre).
+ const { data: availableTypes } = useFetch(fetchVenueTypes)
+ // Typer kombineres som «eller»: Pub og Vinbar gir steder som er det ene eller det andre.
const options = useMemo(
- () => [FAVOURITES, ...types].filter((option) => !filters.includes(option)),
- [types, filters],
+ () => (availableTypes ?? []).filter((type) => !types.includes(type)),
+ [availableTypes, types],
)
- const isFiltering = query.trim() !== '' || filters.length > 0
- const results = useMemo(() => {
- const chosenTypes = filters.filter((filter) => filter !== FAVOURITES)
- const favouriteIds = filters.includes(FAVOURITES)
- ? new Set((user?.favouritePlaces ?? []).map(({ id }) => id))
- : null
-
- return (allPlaces ?? []).filter(
- (place) =>
- (chosenTypes.length === 0 || chosenTypes.includes(place.type)) &&
- (!favouriteIds || favouriteIds.has(place.id)) &&
- matchesQuery(place, query),
- )
- }, [allPlaces, filters, query, user])
-
- // Under søk og filter bladr man gjennom treffene i stedet for listen.
- const shown = isFiltering ? results : places
- const featured: Place | undefined = isFiltering
- ? (results.find(({ id }) => id === selected?.id) ?? results[0])
+ // Under søk og filter følger det fremhevede stedet treffene.
+ const featured: Place | undefined = isFiltered
+ ? (places.find(({ id }) => id === selected?.id) ?? places[0])
: (selected ??
places[Math.floor(startFraction * Math.min(places.length, PAGE_SIZE))])
/** Blar til forrige (-1) eller neste (1) sted, og starter om i endene. */
const step = (direction: -1 | 1) => {
if (!featured) return
- const index = shown.findIndex((place) => place.id === featured.id)
+ const index = places.findIndex((place) => place.id === featured.id)
// Er stedet skjult av «Vis mindre», starter vi i enden av listen.
const next =
index === -1
? direction === 1
? 0
- : shown.length - 1
- : (index + direction + shown.length) % shown.length
+ : places.length - 1
+ : (index + direction + places.length) % places.length
- setSelected(shown[next])
+ select(places[next])
}
return (
setFilters((current) => [...current, filter])}
- onRemoveFilter={(filter) =>
- setFilters((current) => current.filter((item) => item !== filter))
- }
- onReset={() => {
- setQuery('')
- setFilters([])
- }}
+ sort={sort}
+ onQueryChange={setSearch}
+ onAddFilter={addType}
+ onRemoveFilter={removeType}
+ onSortChange={setSort}
+ onReset={reset}
/>
- {isFiltering && !featured && (
+ {status === 'loading' && Laster steder …
}
+ {status === 'idle' && !featured && (
- {allStatus === 'loading'
- ? 'Laster steder …'
- : 'Ingen steder passer søket.'}
+ Ingen steder passer søket.
)}
- {status === 'loading' && Laster steder …
}
{status === 'error' && (
Kunne ikke hente steder. Prøv å laste siden på nytt.
)}
@@ -125,16 +110,16 @@ export function HomePage() {
onNext={() => step(1)}
/>
diff --git a/client/src/stores/catalogStore.test.ts b/client/src/stores/catalogStore.test.ts
new file mode 100644
index 0000000..2b831e3
--- /dev/null
+++ b/client/src/stores/catalogStore.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { makePlace } from '../test/places'
+import { resetCatalogStore, useCatalogStore } from './catalogStore'
+
+const state = () => useCatalogStore.getState()
+
+describe('catalogStore', () => {
+ beforeEach(resetCatalogStore)
+
+ it('starts with an empty search and the name sort', () => {
+ expect(state()).toMatchObject({
+ search: '',
+ types: [],
+ sort: 'NAME',
+ selected: null,
+ })
+ })
+
+ it('adds each type once and removes it again', () => {
+ state().addType('Pub')
+ state().addType('Bar')
+ state().addType('Pub')
+ expect(state().types).toEqual(['Pub', 'Bar'])
+
+ state().removeType('Pub')
+ expect(state().types).toEqual(['Bar'])
+ })
+
+ it('resets search, filters and sort but keeps the selected place', () => {
+ const place = makePlace()
+ state().setSearch('pub')
+ state().addType('Pub')
+ state().setSort('RATING')
+ state().select(place)
+
+ state().reset()
+
+ expect(state()).toMatchObject({
+ search: '',
+ types: [],
+ sort: 'NAME',
+ selected: place,
+ })
+ })
+})
diff --git a/client/src/stores/catalogStore.ts b/client/src/stores/catalogStore.ts
new file mode 100644
index 0000000..6f0079e
--- /dev/null
+++ b/client/src/stores/catalogStore.ts
@@ -0,0 +1,57 @@
+import { create } from 'zustand'
+import {
+ DEFAULT_PLACE_QUERY,
+ type Place,
+ type PlaceSort,
+ type VenueType,
+} from '../types/place'
+
+type CatalogState = {
+ search: string
+ types: VenueType[]
+ sort: PlaceSort
+ /** Hele stedet, så det blir stående selv om listen ikke lenger viser det. */
+ selected: Place | null
+ setSearch: (search: string) => void
+ addType: (type: VenueType) => void
+ removeType: (type: VenueType) => void
+ setSort: (sort: PlaceSort) => void
+ select: (place: Place | null) => void
+ /** Fjerner søk, filter og sortering, men beholder valgt sted. */
+ reset: () => void
+}
+
+const initialState = {
+ search: DEFAULT_PLACE_QUERY.search,
+ types: DEFAULT_PLACE_QUERY.types,
+ sort: DEFAULT_PLACE_QUERY.sort,
+ selected: null,
+}
+
+/**
+ * Delt tilstand for katalogen: det brukeren har valgt. Selve stedene hentes fra
+ * serveren i `usePlaces`, så butikken inneholder bare brukerens valg.
+ */
+export const useCatalogStore = create()((set) => ({
+ ...initialState,
+ setSearch: (search) => set({ search }),
+ addType: (type) =>
+ set((state) =>
+ state.types.includes(type) ? state : { types: [...state.types, type] },
+ ),
+ removeType: (type) =>
+ set((state) => ({ types: state.types.filter((item) => item !== type) })),
+ setSort: (sort) => set({ sort }),
+ select: (selected) => set({ selected }),
+ reset: () =>
+ set({
+ search: initialState.search,
+ types: initialState.types,
+ sort: initialState.sort,
+ }),
+}))
+
+/** Setter butikken tilbake til start. Brukes av tester. */
+export function resetCatalogStore() {
+ useCatalogStore.setState(initialState)
+}
diff --git a/client/src/types/place.ts b/client/src/types/place.ts
index 2fa709c..94e5c10 100644
--- a/client/src/types/place.ts
+++ b/client/src/types/place.ts
@@ -25,3 +25,19 @@ export type PlacesPage = {
items: Place[]
totalCount: number
}
+
+/** Sorteringene backend kan levere. */
+export type PlaceSort = 'NAME' | 'RATING'
+
+/** Det brukeren har bedt om: søkeord, typer og sortering. */
+export type PlaceQuery = {
+ search: string
+ types: VenueType[]
+ sort: PlaceSort
+}
+
+export const DEFAULT_PLACE_QUERY: PlaceQuery = {
+ search: '',
+ types: [],
+ sort: 'NAME',
+}