Skip to content
Open
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
36 changes: 33 additions & 3 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 67 additions & 4 deletions client/src/api/places.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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'])
})
})
125 changes: 94 additions & 31 deletions client/src/api/places.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -32,6 +50,12 @@ const VENUES_QUERY = `
}
`

const VENUE_TYPES_QUERY = `
query VenueTypes {
venueTypes
}
`

type GraphqlOpeningHours = {
day: string
opens: string | null
Expand All @@ -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',
Expand All @@ -70,6 +84,13 @@ const weekdayNames = [
'SATURDAY',
]

/** Stedstypene slik backend skriver dem, og slik de vises i appen. */
const VENUE_TYPE_LABELS: Record<string, VenueType> = {
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'
Expand All @@ -82,16 +103,20 @@ function formatOpeningHours(hours: GraphqlOpeningHours[], now = new Date()) {
}

function toVenueType(type: string): VenueType {
const venueTypes: Record<string, VenueType> = {
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,
Expand All @@ -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<PlacesPage> {
/** Sender én GraphQL-spørring og returnerer `data`, eller kaster en feil. */
async function requestGraphql<T>(
query: string,
variables: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
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<PlacesPage> {
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<VenueType[]> {
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<Place[]> {
const firstPage = await fetchPlaces(0, MAX_PAGE_SIZE)
Expand Down
Loading