From 2afcb70df038d52a0485161c093595f2058a6e6e Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 21 Oct 2026 15:27:53 +0200 Subject: [PATCH 1/2] feat: add catalog store and debounce hook Related to #24 --- client/package-lock.json | 36 ++++++++++++-- client/package.json | 3 +- client/src/hooks/useDebouncedValue.test.ts | 24 +++++++++ client/src/hooks/useDebouncedValue.ts | 13 +++++ client/src/stores/catalogStore.test.ts | 45 +++++++++++++++++ client/src/stores/catalogStore.ts | 57 ++++++++++++++++++++++ 6 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 client/src/hooks/useDebouncedValue.test.ts create mode 100644 client/src/hooks/useDebouncedValue.ts create mode 100644 client/src/stores/catalogStore.test.ts create mode 100644 client/src/stores/catalogStore.ts 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/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/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) +} From 3ecbc36de8d78ffc940aa65eafc2e92aa2d54b3e Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 21 Oct 2026 15:27:53 +0200 Subject: [PATCH 2/2] feat: fetch catalog search, filter and sorting from GraphQL Related to #19, #20, #21 and #22 --- client/src/api/places.test.ts | 71 +++++++++- client/src/api/places.ts | 125 ++++++++++++----- client/src/components/PlaceFilter.tsx | 40 +++++- client/src/components/PlaceList.tsx | 4 +- client/src/hooks/usePlaces.ts | 129 ++++++++++++++---- client/src/pages/HomePage.test.tsx | 185 ++++++++++++++++---------- client/src/pages/HomePage.tsx | 131 ++++++++---------- client/src/types/place.ts | 16 +++ 8 files changed, 488 insertions(+), 213 deletions(-) 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({ onSortChange(event.target.value as PlaceSort)} + > + {sortOptions.map(({ value, label }) => ( + + ))} + +