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({ onSortChange(event.target.value as PlaceSort)} + > + {sortOptions.map(({ value, label }) => ( + + ))} + +