From 3cbcee7194d2cf4a00e505ec3c45a1d25a1b1419 Mon Sep 17 00:00:00 2001 From: Andreas Date: Mon, 12 Oct 2026 15:26:16 +0200 Subject: [PATCH 1/6] Add GraphQL catalog integration and pagination tests --- package-lock.json | 150 +++++++++- package.json | 5 +- src/App.test.tsx | 171 ++++++++++-- src/App.tsx | 528 +++++++++++++++++++++++++++++++---- src/graphql/mocks.ts | 51 ++++ src/graphql/queries.test.tsx | 75 +++++ src/graphql/queries.ts | 59 ++++ src/graphql/types.ts | 46 +++ src/lib/apollo.ts | 8 + src/main.tsx | 7 +- src/vite-env.d.ts | 1 + vite.config.ts | 12 + 12 files changed, 1035 insertions(+), 78 deletions(-) create mode 100644 src/graphql/mocks.ts create mode 100644 src/graphql/queries.test.tsx create mode 100644 src/graphql/queries.ts create mode 100644 src/graphql/types.ts create mode 100644 src/lib/apollo.ts create mode 100644 src/vite-env.d.ts diff --git a/package-lock.json b/package-lock.json index fc2f342..5de67a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "spillkveld", "version": "0.1.0", "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^9.35.0", @@ -37,6 +40,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@apollo/client": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", + "integrity": "sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1064,6 +1104,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2190,6 +2239,54 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -3016,6 +3113,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3439,6 +3560,18 @@ "dev": true, "license": "MIT" }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3767,6 +3900,15 @@ "dev": true, "license": "MIT" }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4043,6 +4185,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 9efc2c1..53322fe 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,11 @@ "test": "vitest run" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/src/App.test.tsx b/src/App.test.tsx index 9ddf0a9..3a93915 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,45 +1,174 @@ + import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { cleanup, render, screen, within } from '@testing-library/react' +import {cleanup, render, screen, waitFor, within,} from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { ApolloClient, InMemoryCache } from '@apollo/client' +import { ApolloProvider } from '@apollo/client/react' import App from './App' +import { createCatalogMockLink } from './graphql/mocks' beforeEach(() => localStorage.clear()) afterEach(cleanup) +// Gir App en Apollo Client med simulerte GraphQL-svar. +function renderApp() { + const client = new ApolloClient({ + link: createCatalogMockLink(), + cache: new InMemoryCache(), + }) + + return render( + + + , + ) +} + describe('Spillkveld', () => { it('keeps search state when opening and closing a game', async () => { const user = userEvent.setup() - render() - await user.type(screen.getByRole('searchbox', { name: 'Søk etter spill' }), 'Azul') - expect(screen.getByRole('status')).toHaveTextContent('1 spill funnet') + renderApp() + + await user.type( + screen.getByRole('searchbox', { name: 'Søk etter spill' }), + 'Azul', + ) + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('1 spill funnet') + }) + await user.click(screen.getByRole('button', { name: 'Azul' })) - expect(screen.getByRole('heading', { name: 'Azul', level: 1 })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: /Tilbake til resultatene/ })) - expect(screen.getByRole('searchbox', { name: 'Søk etter spill' })).toHaveValue('Azul') - expect(screen.getByRole('button', { name: 'Azul' })).toHaveFocus() + + expect( + screen.getByRole('heading', { name: 'Azul', level: 1 }), + ).toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: /Tilbake til resultatene/ }), + ) + + expect( + screen.getByRole('searchbox', { name: 'Søk etter spill' }), + ).toHaveValue('Azul') + + expect( + screen.getByRole('button', { name: 'Azul' }), + ).toHaveFocus() }) it('shows an actionable empty state', async () => { const user = userEvent.setup() - render() - await user.type(screen.getByRole('searchbox', { name: 'Søk etter spill' }), 'finnesikke') - expect(screen.getByRole('heading', { name: 'Ingen spill passer søket' })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Vis alle spill' })) - expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + renderApp() + + await user.type( + screen.getByRole('searchbox', { name: 'Søk etter spill' }), + 'finnesikke', + ) + + expect( + await screen.findByRole('heading', { + name: 'Ingen spill passer søket', + }), + ).toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: 'Vis alle spill' }), + ) + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + }) }) it('adds, persists and removes a game from the selection', async () => { const user = userEvent.setup() - const view = render() - await user.click(screen.getByRole('button', { name: 'Legg til Azul i Kveldens utvalg' })) - const selection = screen.getByRole('region', { name: /Kveldens utvalg/ }) + const view = renderApp() + + await user.click( + await screen.findByRole('button', { + name: 'Legg til Azul i Kveldens utvalg', + }), + ) + + const selection = screen.getByRole('region', { + name: /Kveldens utvalg/, + }) + expect(within(selection).getByText('Azul')).toBeInTheDocument() - expect(JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]')).toEqual(['azul']) + + expect( + JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]'), + ).toEqual(['azul']) + view.unmount() - render() - const restored = screen.getByRole('region', { name: /Kveldens utvalg/ }) + + renderApp() + + const restored = screen.getByRole('region', { + name: /Kveldens utvalg/, + }) + expect(within(restored).getByText('Azul')).toBeInTheDocument() - await user.click(within(restored).getByRole('button', { name: 'Fjern Azul fra Kveldens utvalg' })) - expect(within(restored).queryByText('Azul')).not.toBeInTheDocument() + + await user.click( + within(restored).getByRole('button', { + name: 'Fjern Azul fra Kveldens utvalg', + }), + ) + + expect( + within(restored).queryByText('Azul'), + ).not.toBeInTheDocument() + }) + + it('paginates games using GraphQL', async () => { + const user = userEvent.setup() + renderApp() + + // Vent på første side. + expect( + await screen.findByRole('button', { name: 'Azul' }), + ).toBeInTheDocument() + + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + + // Kontroller side 1. + expect( + within( + screen.getByRole('navigation', { name: 'Resultatsider' }), + ).getByText('Side 1 av 2'), + ).toBeInTheDocument() + + // Gå til side 2. + await user.click( + within( + screen.getByRole('navigation', { name: 'Resultatsider' }), + ).getByRole('button', { name: /Neste/ }), + ) + + // Vent til den nye siden er ferdig lastet. + await waitFor(() => { + const pagination = screen.getByRole('navigation', { + name: 'Resultatsider', + }) + + expect( + within(pagination).getByText('Side 2 av 2'), + ).toBeInTheDocument() + + expect( + within(pagination).getByRole('button', { name: /Forrige/ }), + ).toBeEnabled() + + expect( + within(pagination).getByRole('button', { name: /Neste/ }), + ).toBeDisabled() }) + + // Spill fra side 1 skal ikke lenger vises i katalogen. + expect( + screen.queryByRole('button', { name: 'Azul' }), + ).not.toBeInTheDocument() +}) }) diff --git a/src/App.tsx b/src/App.tsx index 0ffa795..7536155 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,64 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { games, type Game } from './data/games' -import { filterGames, pageGames, PAGE_SIZE, type Filters } from './lib/catalog' +import { PAGE_SIZE, type Filters } from './lib/catalog' import { readSelection, saveSelection } from './lib/selection' -const initialFilters: Filters = { query: '', players: null, maxMinutes: null, category: '', sort: 'name' } -const categories = [...new Set(games.map((game) => game.category))].sort((a, b) => a.localeCompare(b, 'no')) +import { useQuery } from '@apollo/client/react' +import { GET_GAMES } from './graphql/queries' +import type { GamesData, GamesVariables } from './graphql/types' + +const initialFilters: Filters = { + query: '', + players: null, + maxMinutes: null, + category: '', + sort: 'name', +} + +const categories = [...new Set(games.map((game) => game.category))].sort( + (a, b) => a.localeCompare(b, 'no'), +) + +function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timeout = window.setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => window.clearTimeout(timeout) + }, [value, delay]) + + return debouncedValue +} function GameArt({ game }: { game: Game }) { - return + return ( + + ) } -function SelectionButton({ game, selected, onToggle }: { game: Game; selected: boolean; onToggle: (id: string) => void }) { +function SelectionButton({ + game, + selected, + onToggle, +}: { + game: Game + selected: boolean + onToggle: (id: string) => void +}) { return ( - ) } @@ -22,21 +66,51 @@ function SelectionButton({ game, selected, onToggle }: { game: Game; selected: b export default function App() { const [filters, setFilters] = useState(initialFilters) const [page, setPage] = useState(1) + + const debouncedQuery = useDebouncedValue(filters.query, 300) + + const { data, previousData, loading, error } = + useQuery( + GET_GAMES, + { + variables: { + input: { + query: debouncedQuery, + players: filters.players, + maxMinutes: filters.maxMinutes, + category: filters.category, + sort: filters.sort === 'name' ? 'NAME' : 'TIME', + page, + pageSize: PAGE_SIZE, + }, + }, + }, + ) + const [selectedId, setSelectedId] = useState(null) - const [selection, setSelection] = useState(() => readSelection(games.map((game) => game.id))) + const [selection, setSelection] = useState(() => + readSelection(games.map((game) => game.id)), + ) + const headingRef = useRef(null) const returningGameRef = useRef(null) - const results = useMemo(() => filterGames(games, filters), [filters]) - const pageCount = Math.max(1, Math.ceil(results.length / PAGE_SIZE)) - const visibleGames = pageGames(results, page) + const totalCount = data?.games.totalCount ?? previousData?.games.totalCount ?? 0 + const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + const visibleGames = data?.games.items ?? [] const detail = games.find((game) => game.id === selectedId) - const selectedGames = selection.map((id) => games.find((game) => game.id === id)).filter((game): game is Game => Boolean(game)) + + const selectedGames = selection + .map((id) => games.find((game) => game.id === id)) + .filter((game): game is Game => Boolean(game)) useEffect(() => { - if (selectedId) headingRef.current?.focus() - else if (returningGameRef.current) { - document.getElementById(`open-${returningGameRef.current}`)?.focus() + if (selectedId) { + headingRef.current?.focus() + } else if (returningGameRef.current) { + document + .getElementById(`open-${returningGameRef.current}`) + ?.focus() returningGameRef.current = null } }, [selectedId]) @@ -47,7 +121,10 @@ export default function App() { } function toggleSelection(id: string) { - const next = selection.includes(id) ? selection.filter((item) => item !== id) : [...selection, id] + const next = selection.includes(id) + ? selection.filter((item) => item !== id) + : [...selection, id] + setSelection(next) saveSelection(next) } @@ -59,32 +136,89 @@ export default function App() { return (
- Hopp til innhold + + Hopp til innhold + +
- - Kveldens utvalg {selection.length} + + + Kveldens utvalg {selection.length} +
{detail ? (
- + +
+
- {detail.category} · Eksempeldata -

{detail.name}

-

{detail.description}

+ + {detail.category} · Eksempeldata + + +

+ {detail.name} +

+ +

+ {detail.description} +

+
-
Spillere
{detail.minPlayers === detail.maxPlayers ? detail.minPlayers : `${detail.minPlayers}–${detail.maxPlayers}`}
-
Spilletid
Ca. {detail.minutes} min
-
Kategori
{detail.category}
+
+
Spillere
+
+ {detail.minPlayers === detail.maxPlayers + ? detail.minPlayers + : `${detail.minPlayers}–${detail.maxPlayers}`} +
+
+ +
+
Spilletid
+
Ca. {detail.minutes} min
+
+ +
+
Kategori
+
{detail.category}
+
- + +
@@ -92,49 +226,335 @@ export default function App() { <>
- ✳ Finn noe fint å spille -

Neste spillkveld starter her.

-

Finn et spill som passer gjengen og tiden dere har. Samle favorittene til kvelden på ett sted.

+ + ✳ Finn noe fint å spille + + +

+ Neste spillkveld starter her. +

+ +

+ Finn et spill som passer gjengen og tiden dere har. + Samle favorittene til kvelden på ett sted. +

+
+ + -
-
Utforsk katalogen

Finn deres neste favoritt

Statiske eksempeldata
+
+
+ Utforsk katalogen +

Finn deres neste favoritt

+
+ + + Statiske eksempeldata + +
+
- -
updateFilters({ query: event.target.value })} />
+ + +
+ + + updateFilters({ query: event.target.value }) + } + /> +
+
-
-
-
-
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
- + + +
+ +
+

+ {totalCount === 1 + ? '1 spill funnet' + : `${totalCount} spill funnet`} +

+ + {totalCount > 0 && ( + + Side {page} av {pageCount} + + )}
-

{results.length === 1 ? '1 spill funnet' : `${results.length} spill funnet`}

{results.length > 0 && Side {page} av {pageCount}}
- {results.length === 0 ? ( -

Ingen spill passer søket

Prøv et annet ord eller endre filtrene.

+ {loading ? ( +
+

Laster spill …

+
+ ) : error ? ( +
+

Kunne ikke hente spill

+

Prøv igjen senere.

+
+ ) : totalCount === 0 ? ( +
+ +

Ingen spill passer søket

+

Prøv et annet ord eller endre filtrene.

+ + +
) : ( <> -
{visibleGames.map((game) =>
- -
{game.category}

{game.description}

♟ {game.minPlayers === game.maxPlayers ? game.minPlayers : `${game.minPlayers}–${game.maxPlayers}`} spillere◷ {game.minutes} min
-
)}
- {pageCount > 1 && } +
+ {visibleGames.map((game) => ( +
+ + +
+ + {game.category} + + +

+ +

+ +

{game.description}

+ +
+ + ♟{' '} + {game.minPlayers === game.maxPlayers + ? game.minPlayers + : `${game.minPlayers}–${game.maxPlayers}`}{' '} + spillere + + ◷ {game.minutes} min +
+ + +
+
+ ))} +
+ + {pageCount > 1 && ( + + )} )}
)} -
-
Din liste

Kveldens utvalg ({selection.length})

Lagret i denne nettleseren

- {selectedGames.length === 0 ?

Ingen spill lagt til ennå. Velg noen favoritter fra katalogen over.

:
    {selectedGames.map((game) =>
  • {game.name}{game.minPlayers === game.maxPlayers ? game.minPlayers : `${game.minPlayers}–${game.maxPlayers}`} spillere · {game.minutes} min
  • )}
} +
+
+
+ Din liste +

+ Kveldens utvalg ({selection.length}) +

+
+

Lagret i denne nettleseren

+
+ + {selectedGames.length === 0 ? ( +
+ +

+ Ingen spill lagt til ennå. Velg noen favoritter fra + katalogen over. +

+
+ ) : ( +
    + {selectedGames.map((game) => ( +
  • + + +
    + {game.name} + + {game.minPlayers === game.maxPlayers + ? game.minPlayers + : `${game.minPlayers}–${game.maxPlayers}`}{' '} + spillere · {game.minutes} min + +
    + + +
  • + ))} +
+ )}
-
spillkveld.

En prototype med statiske eksempeldata · IT2810

+ +
+
+ + spillkveld. + +

En prototype med statiske eksempeldata · IT2810

+
+
) } diff --git a/src/graphql/mocks.ts b/src/graphql/mocks.ts new file mode 100644 index 0000000..fb57b8c --- /dev/null +++ b/src/graphql/mocks.ts @@ -0,0 +1,51 @@ + +import { MockLink } from '@apollo/client/testing' +import { games } from '../data/games' +import { GET_GAMES } from './queries' +import type { CatalogInput } from './types' +import { filterGames, PAGE_SIZE } from '../lib/catalog' + +function getCatalogResult(input: CatalogInput) { + const filteredGames = filterGames(games, { + query: input.query, + players: input.players, + maxMinutes: input.maxMinutes, + category: input.category, + sort: input.sort === 'NAME' ? 'name' : 'minutes', + }) + + const pageSize = input.pageSize || PAGE_SIZE + const start = (input.page - 1) * pageSize + + return { + games: { + items: filteredGames.slice(start, start + pageSize), + totalCount: filteredGames.length, + page: input.page, + pageSize, + }, + } +} + +export function createCatalogMockLink() { + return new MockLink([ + { + request: { + query: GET_GAMES, + variables: (variables) => { + const input = variables.input as CatalogInput + return Boolean(input) + }, + }, + result: (variables) => { + const input = variables.input as CatalogInput + + return { + data: getCatalogResult(input), + } + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + ]) +} + diff --git a/src/graphql/queries.test.tsx b/src/graphql/queries.test.tsx new file mode 100644 index 0000000..77a8187 --- /dev/null +++ b/src/graphql/queries.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import { ApolloClient, InMemoryCache } from '@apollo/client' +import { MockLink } from '@apollo/client/testing' +import { ApolloProvider, useQuery } from '@apollo/client/react' +import { describe, expect, it } from 'vitest' +import { GET_GAMES } from './queries' +import type { GamesData, GamesVariables } from './types' + +const input = { + query: '', + players: null, + maxMinutes: null, + category: '', + sort: 'NAME' as const, + page: 1, + pageSize: 6, +} + +function TestCatalog() { + const { data, loading, error } = useQuery( + GET_GAMES, + { variables: { input } }, + ) + + if (loading) return

Laster spill...

+ if (error) return

Kunne ikke hente spill

+ + return

{data?.games.items[0]?.name ?? 'Ingen spill'}

+} + +describe('GET_GAMES', () => { + it('henter spill fra en simulert GraphQL-server', async () => { + const client = new ApolloClient({ + link: new MockLink([ + { + request: { + query: GET_GAMES, + variables: { input }, + }, + result: { + data: { + games: { + items: [ + { + id: 'azul', + name: 'Azul', + description: 'Legg fargerike fliser.', + category: 'Strategi', + minPlayers: 2, + maxPlayers: 4, + minutes: 40, + icon: '▦', + tone: 'blue', + }, + ], + totalCount: 1, + page: 1, + pageSize: 6, + }, + }, + }, + }, + ]), + cache: new InMemoryCache(), + }) + + render( + + + , + ) + + expect(await screen.findByText('Azul')).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts new file mode 100644 index 0000000..6777cda --- /dev/null +++ b/src/graphql/queries.ts @@ -0,0 +1,59 @@ +import { gql } from '@apollo/client' + +export const GET_GAMES = gql` + query GetGames($input: CatalogInput) { + games(input: $input) { + items { + id + name + description + minPlayers + maxPlayers + minutes + category + icon + tone + } + totalCount + page + pageSize + } + } +` +export const GET_GAME = gql` + query GetGame($id: ID!) { + game(id: $id) { + id + name + description + minPlayers + maxPlayers + minutes + category + icon + tone + } + } +` + +export const GET_GAMES_BY_IDS = gql` + query GetGamesByIds($ids: [ID!]!) { + gamesByIds(ids: $ids) { + id + name + description + minPlayers + maxPlayers + minutes + category + icon + tone + } + } +` + +export const GET_CATEGORIES = gql` + query GetCategories { + categories + } +` \ No newline at end of file diff --git a/src/graphql/types.ts b/src/graphql/types.ts new file mode 100644 index 0000000..8719f35 --- /dev/null +++ b/src/graphql/types.ts @@ -0,0 +1,46 @@ +import type { Game } from '../data/games' + +export type { Game } from '../data/games' + +export type CatalogInput = { + query: string + players: number | null + maxMinutes: number | null + category: string + sort: 'NAME' | 'TIME' + page: number + pageSize: number +} + +export type GamesData = { + games: { + items: Game[] + totalCount: number + page: number + pageSize: number + } +} + +export type GamesVariables = { + input: CatalogInput +} + +export type GameData = { + game: Game | null +} + +export type GameVariables = { + id: string +} + +export type GamesByIdsData = { + gamesByIds: Game[] +} + +export type GamesByIdsVariables = { + ids: string[] +} + +export type CategoriesData = { + categories: string[] +} \ No newline at end of file diff --git a/src/lib/apollo.ts b/src/lib/apollo.ts new file mode 100644 index 0000000..e4eb110 --- /dev/null +++ b/src/lib/apollo.ts @@ -0,0 +1,8 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client' + +export const apolloClient = new ApolloClient({ + link: new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_URL || '/graphql', + }), + cache: new InMemoryCache(), +}) diff --git a/src/main.tsx b/src/main.tsx index 6906b28..ab36293 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,15 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { ApolloProvider } from '@apollo/client/react' import App from './App' +import { apolloClient } from './lib/apollo' import './styles.css' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ) + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index cc4cfed..cb8b5b8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,18 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + + server: { + port: 5173, + strictPort: true, + proxy: { + '/graphql': { + target: 'http://127.0.0.1:3001', + changeOrigin: true, + }, + }, + }, + test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], From ac67cf9072b51e0471f1b7a06b12142f79abeaea Mon Sep 17 00:00:00 2001 From: Andreas Date: Mon, 12 Oct 2026 17:20:59 +0200 Subject: [PATCH 2/6] Complete GraphQL integration for game details, categories and selection --- src/App.test.tsx | 111 ++++++++++++++++++++++++++----------------- src/App.tsx | 104 ++++++++++++++++++++++++++++++---------- src/graphql/mocks.ts | 69 ++++++++++++++++++++++++--- src/lib/selection.ts | 19 ++++++-- 4 files changed, 225 insertions(+), 78 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 3a93915..9ea4f2b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -41,7 +41,7 @@ describe('Spillkveld', () => { await user.click(screen.getByRole('button', { name: 'Azul' })) expect( - screen.getByRole('heading', { name: 'Azul', level: 1 }), + await screen.findByRole('heading', { name: 'Azul', level: 1 }), ).toBeInTheDocument() await user.click( @@ -81,10 +81,12 @@ describe('Spillkveld', () => { }) }) + it('adds, persists and removes a game from the selection', async () => { const user = userEvent.setup() const view = renderApp() + // Legg til Azul i utvalget. await user.click( await screen.findByRole('button', { name: 'Legg til Azul i Kveldens utvalg', @@ -95,80 +97,101 @@ describe('Spillkveld', () => { name: /Kveldens utvalg/, }) - expect(within(selection).getByText('Azul')).toBeInTheDocument() + // Vent på GraphQL-responsen. + expect( + await within(selection).findByText('Azul'), + ).toBeInTheDocument() + // Kontroller at ID-en er lagret. expect( JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]'), ).toEqual(['azul']) + // Simuler at appen åpnes på nytt. view.unmount() - renderApp() const restored = screen.getByRole('region', { name: /Kveldens utvalg/, }) - expect(within(restored).getByText('Azul')).toBeInTheDocument() + // Vent på at GraphQL henter det lagrede spillet. + expect( + await within(restored).findByText('Azul'), + ).toBeInTheDocument() + // Fjern Azul fra utvalget. await user.click( within(restored).getByRole('button', { name: 'Fjern Azul fra Kveldens utvalg', }), ) + await waitFor(() => { + expect( + within(restored).queryByText('Azul'), + ).not.toBeInTheDocument() + }) + + expect( - within(restored).queryByText('Azul'), - ).not.toBeInTheDocument() + JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]'), + ).toEqual([]) }) it('paginates games using GraphQL', async () => { - const user = userEvent.setup() - renderApp() - - // Vent på første side. - expect( - await screen.findByRole('button', { name: 'Azul' }), - ).toBeInTheDocument() - - expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') - - // Kontroller side 1. - expect( - within( - screen.getByRole('navigation', { name: 'Resultatsider' }), - ).getByText('Side 1 av 2'), - ).toBeInTheDocument() - - // Gå til side 2. - await user.click( - within( - screen.getByRole('navigation', { name: 'Resultatsider' }), - ).getByRole('button', { name: /Neste/ }), - ) + const user = userEvent.setup() + renderApp() - // Vent til den nye siden er ferdig lastet. - await waitFor(() => { - const pagination = screen.getByRole('navigation', { - name: 'Resultatsider', + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent( + '12 spill funnet', + ) }) - expect( - within(pagination).getByText('Side 2 av 2'), - ).toBeInTheDocument() + expect(screen.getAllByText('Side 1 av 2').length).toBeGreaterThan(0) + + await user.click( + screen.getByRole('button', { name: /Neste/ }), + ) + + await waitFor(() => { + expect(screen.getAllByText('Side 2 av 2').length).toBeGreaterThan(0) + }) expect( - within(pagination).getByRole('button', { name: /Forrige/ }), + await screen.findByRole('button', { name: /Forrige/ }), ).toBeEnabled() + }) + + it('loads game details through GraphQL', async () => { + const user = userEvent.setup() + renderApp() + + await user.click( + await screen.findByRole('button', { name: 'Azul' }), + ) expect( - within(pagination).getByRole('button', { name: /Neste/ }), - ).toBeDisabled() + await screen.findByRole('heading', { + name: 'Azul', + level: 1, + }), + ).toBeInTheDocument() }) - // Spill fra side 1 skal ikke lenger vises i katalogen. - expect( - screen.queryByRole('button', { name: 'Azul' }), - ).not.toBeInTheDocument() -}) + it('loads categories through GraphQL', async () => { + renderApp() + + const categorySelect = screen.getByRole('combobox', { + name: /Kategori/, + }) + + await waitFor(() => { + expect( + within(categorySelect).getAllByRole('option').length, + ).toBeGreaterThan(1) + }) + }) }) + diff --git a/src/App.tsx b/src/App.tsx index 7536155..e668ea1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,18 @@ import { useEffect, useRef, useState } from 'react' -import { games, type Game } from './data/games' +import type { Game } from './data/games' import { PAGE_SIZE, type Filters } from './lib/catalog' import { readSelection, saveSelection } from './lib/selection' - import { useQuery } from '@apollo/client/react' -import { GET_GAMES } from './graphql/queries' -import type { GamesData, GamesVariables } from './graphql/types' +import { GET_CATEGORIES, GET_GAME, GET_GAMES, GET_GAMES_BY_IDS,} from './graphql/queries' +import type { + CategoriesData, + GameData, + GameVariables, + GamesByIdsData, + GamesByIdsVariables, + GamesData, + GamesVariables, +} from './graphql/types' const initialFilters: Filters = { query: '', @@ -14,11 +21,6 @@ const initialFilters: Filters = { category: '', sort: 'name', } - -const categories = [...new Set(games.map((game) => game.category))].sort( - (a, b) => a.localeCompare(b, 'no'), -) - function useDebouncedValue(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value) @@ -87,10 +89,36 @@ export default function App() { }, ) - const [selectedId, setSelectedId] = useState(null) - const [selection, setSelection] = useState(() => - readSelection(games.map((game) => game.id)), - ) +const { + data: categoriesData, + loading: categoriesLoading, + error: categoriesError, +} = useQuery(GET_CATEGORIES) + +const categories = categoriesData?.categories ?? [] + +const [selectedId, setSelectedId] = useState(null) + + const { + data: detailData, + loading: detailLoading, + error: detailError, + } = useQuery(GET_GAME, { + variables: { id: selectedId ?? '' }, + skip: selectedId === null, + }) + +const [selection, setSelection] = useState(() => + readSelection(), +) + +const { data: selectionData, loading: selectionLoading, error: selectionError, +} = useQuery(GET_GAMES_BY_IDS, + { + variables: { ids: selection }, + skip: selection.length === 0, + }, +) const headingRef = useRef(null) const returningGameRef = useRef(null) @@ -98,11 +126,10 @@ export default function App() { const totalCount = data?.games.totalCount ?? previousData?.games.totalCount ?? 0 const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const visibleGames = data?.games.items ?? [] - const detail = games.find((game) => game.id === selectedId) + const detail = selectedId ? detailData?.game : null - const selectedGames = selection - .map((id) => games.find((game) => game.id === id)) - .filter((game): game is Game => Boolean(game)) +const selectedGames = + selectionData?.gamesByIds ?? [] useEffect(() => { if (selectedId) { @@ -167,7 +194,19 @@ export default function App() {
- {detail ? ( + {selectedId ? ( + detailLoading ? ( +
+

Laster spilldetaljer …

+
+ ) : detailError || !detail ? ( +
+

Kunne ikke hente spilldetaljer

+ +
+ ) : (
+ + ) ) : ( <>
@@ -331,10 +371,18 @@ export default function App() { updateFilters({ category: event.target.value }) } > - - {categories.map((category) => ( - - ))} + + {categories.map((category) => ( + + ))} @@ -504,7 +552,15 @@ export default function App() {

Lagret i denne nettleseren

- {selectedGames.length === 0 ? ( + {selectionLoading ? ( +
+

Laster utvalgte spill …

+
+ ) : selectionError ? ( +
+

Kunne ikke hente utvalgte spill.

+
+ ) : selectedGames.length === 0 ? (

diff --git a/src/graphql/mocks.ts b/src/graphql/mocks.ts index fb57b8c..900d508 100644 --- a/src/graphql/mocks.ts +++ b/src/graphql/mocks.ts @@ -1,7 +1,6 @@ - import { MockLink } from '@apollo/client/testing' import { games } from '../data/games' -import { GET_GAMES } from './queries' +import { GET_CATEGORIES, GET_GAME, GET_GAMES, GET_GAMES_BY_IDS,} from './queries' import type { CatalogInput } from './types' import { filterGames, PAGE_SIZE } from '../lib/catalog' @@ -27,25 +26,81 @@ function getCatalogResult(input: CatalogInput) { } } + + export function createCatalogMockLink() { return new MockLink([ { - request: { + request: { query: GET_GAMES, variables: (variables) => { - const input = variables.input as CatalogInput - return Boolean(input) - }, + const input = variables.input as CatalogInput + return Boolean(input) }, + }, result: (variables) => { const input = variables.input as CatalogInput - return { data: getCatalogResult(input), } }, maxUsageCount: Number.POSITIVE_INFINITY, }, + { + request: { + query: GET_GAME, + variables: (variables) => { + return typeof variables.id === 'string' + }, + }, + result: (variables) => { + const game = games.find( + (item) => item.id === variables.id, + ) + + return { + data: { + game: game ?? null, + }, + } + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + { + request: { + query: GET_CATEGORIES, + }, + result: { + data: { + categories: [...new Set(games.map((game) => game.category))].sort( + (a, b) => a.localeCompare(b, 'no'), + ), + }, + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + { + request: { + query: GET_GAMES_BY_IDS, + variables: (variables) => { + return Array.isArray(variables.ids) + }, + }, + result: (variables) => { + const ids = variables.ids as string[] + + return { + data: { + gamesByIds: ids + .map((id) => games.find((game) => game.id === id)) + .filter((game) => game !== undefined), + }, + } + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, ]) } + + diff --git a/src/lib/selection.ts b/src/lib/selection.ts index a463878..89592fd 100644 --- a/src/lib/selection.ts +++ b/src/lib/selection.ts @@ -1,10 +1,22 @@ + const STORAGE_KEY = 'spillkveld-selection-v1' -export function readSelection(validIds: string[]): string[] { +export function readSelection(): string[] { try { - const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown + const saved = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? '[]', + ) as unknown + if (!Array.isArray(saved)) return [] - return [...new Set(saved.filter((id): id is string => typeof id === 'string' && validIds.includes(id)))] + + return [ + ...new Set( + saved.filter( + (id): id is string => + typeof id === 'string' && id.length > 0, + ), + ), + ] } catch { return [] } @@ -17,3 +29,4 @@ export function saveSelection(ids: string[]): void { // Appen fungerer fortsatt når nettleseren blokkerer lagring. } } + From d45d0c5f6ea179e2bcf98afb5d3d2a21fe8a165d Mon Sep 17 00:00:00 2001 From: Andreas Date: Mon, 12 Oct 2026 19:49:49 +0200 Subject: [PATCH 3/6] Configure frontend tooling, tests and documentation --- .env.example | 4 + .prettierignore | 5 + .prettierrc.json | 7 + docs/frontend.md | 301 +++++++++++++++++++++++++++++++++++ package-lock.json | 63 ++++++++ package.json | 15 +- src/App.test.tsx | 96 +++++++---- src/App.tsx | 288 +++++++++++++++++---------------- src/data/games.ts | 148 +++++++++++++++-- src/graphql/mocks.ts | 16 +- src/graphql/queries.test.tsx | 2 +- src/graphql/queries.ts | 2 +- src/graphql/types.ts | 2 +- src/lib/catalog.test.ts | 33 +++- src/lib/catalog.ts | 25 ++- src/lib/selection.ts | 5 +- src/main.tsx | 1 - src/vite-env.d.ts | 2 +- vite.config.ts | 1 + 19 files changed, 788 insertions(+), 228 deletions(-) create mode 100644 .env.example create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 docs/frontend.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..815ba5e --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# GraphQL-endepunkt for frontend. +# La variabelen være tom ved lokal utvikling for å bruke Vite-proxyen. +# Ved produksjonsbygg kan den settes til /project2/graphql. +VITE_GRAPHQL_URL= \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4c09c27 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +playwright-report +test-results \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a06a385 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": false, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 80 +} diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..7ca3219 --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,301 @@ +# Frontend Documentation + +## 1. Introduction + +The frontend of *Spillkveld* is developed using React and TypeScript, with Vite as +the development and build tool. + +The application allows users to browse a catalog of board games, search for specific games, +apply filters, sort results, and view detailed information about individual games. +Users can also create a personal selection of games, which is preserved in the browser +using local storage. + +The frontend uses Apollo Client to manage GraphQL queries and communicate with the backend. +During frontend development, mocked GraphQL responses are used to test functionality +independently of the backend implementation. + +## 2. Technology Stack + +The frontend uses the following technologies: + +- **React:** Component-based user interface. +- **TypeScript:** Static typing for improved maintainability and reliability. +- **Vite:** Development server and production build tool. +- **Apollo Client:** GraphQL queries, caching, and client-side data management. +- **GraphQL:** API interface between the frontend and backend. +- **Vitest:** Unit and component testing. +- **React Testing Library:** Testing user interactions and rendered components. +- **Prettier:** Consistent code formatting. +- **ESLint:** Static code analysis and linting. +- **Playwright:** Dependency and test command prepared for end-to-end testing. + +## 3. Frontend Architecture + +The frontend is organized into separate modules for the user interface, GraphQL operations, +application data, and utility functions. + +### 3.1 Project Structure + +The main frontend files and directories are: + +- `src/App.tsx` – Main application component, including the game catalog, search, filtering, + sorting, pagination, game details, and game selection. +- `src/main.tsx` – Application entry point and Apollo Provider setup. +- `src/lib/apollo.ts` – Apollo Client configuration and GraphQL endpoint. +- `src/graphql/queries.ts` – GraphQL queries used by the frontend. +- `src/graphql/types.ts` – TypeScript definitions for GraphQL data and variables. +- `src/graphql/mocks.ts` – Mocked GraphQL responses used during frontend development and testing. +- `src/data/games.ts` – Sample board game data used by the mock implementation. +- `src/lib/catalog.ts` – Catalog filtering, sorting, and pagination logic for mocked data. +- `src/lib/selection.ts` – Local storage utilities for managing the user's selected games. +- `src/test/setup.ts` – Test environment configuration. + +### 3.2 Apollo Client and GraphQL + +Apollo Client is configured in `src/lib/apollo.ts` and provided to the React application +through `ApolloProvider` in `src/main.tsx`. + +The frontend uses GraphQL queries to retrieve game information instead of accessing + the sample data directly from the UI components. + +The following operations are defined: + +- `GET_GAMES` – Retrieves a paginated game catalog with search, filtering, and sorting parameters. +- `GET_GAME` – Retrieves detailed information about a specific game. +- `GET_CATEGORIES` – Retrieves the available game categories. +- `GET_GAMES_BY_IDS` – Retrieves games belonging to the user's selection. + +The GraphQL endpoint can be configured through the `VITE_GRAPHQL_URL` environment +variable. During local development, the default `/graphql` path is forwarded to the +backend through the Vite development proxy. + +### 3.3 Mocked Data + +The frontend can be tested independently of the backend by using Apollo's `MockLink`. + +Mocked GraphQL responses are generated from the sample games in `src/data/games.ts`. +The mock implementation supports the same queries and input parameters expected +by the frontend. + +This allows frontend functionality + +## 4. User Interface and Features + +### 4.1 Game Catalog + +The main page displays a catalog of board games. Each game is presented with relevant +information, allowing users to browse the available games and open a detailed view. + +The catalog data is retrieved through the `GET_GAMES` GraphQL query using Apollo Client. + +The interface includes loading, error, and empty-result states to provide feedback +when data is being fetched, a request fails, or no games match the selected criteria. + +### 4.2 Search and Filtering + +Users can search for board games by entering text in the search field. + +The search input uses a 300-millisecond debounce to avoid sending a new query for +every keystroke. + +The catalog also supports filtering based on player count, maximum playing time, +and game category. + +These parameters are passed to the `GET_GAMES` GraphQL query, allowing the catalog +results to reflect the user's selected criteria. + +### 4.3 Sorting and Pagination + +Users can sort the game catalog by name or playing time. + +The catalog uses pagination, displaying six games per page. Users can navigate +between pages to browse additional results. + +Search, filtering, sorting, and pagination are handled through GraphQL query variables. + +### 4.4 Game Details + +Users can open a game to view additional information, including its description, +player count, playing time, and category. + +The detailed information is retrieved through the `GET_GAME` GraphQL query. + +The interface also handles loading and error states when retrieving game details. + +### 4.5 Game Selection + +Users can add games to a personal selection and remove games from it. + +The selection is stored in the browser's `localStorage`, allowing it to persist +across page reloads. + +The application stores the selected game IDs rather than complete game objects. +The corresponding game information is retrieved using the `GET_GAMES_BY_IDS` GraphQL query. + +This separates the user's locally stored selection from the game data provided +through GraphQL. + +## 5. Testing and Code Quality + +### 5.1 Unit and Component Testing + +The frontend uses Vitest and React Testing Library for automated testing. + +The tests cover both application logic and user interactions, including: + +- Searching for games and opening game details. +- Displaying an empty state when no games match the search. +- Adding and removing games from the selection. +- Persisting selected games using local storage. +- Navigating between catalog pages. +- Retrieving game details and categories through GraphQL. +- Handling GraphQL request failures and retrying failed requests. +- Catalog filtering, sorting, and pagination logic. + +Apollo's `MockLink` is used to provide controlled GraphQL responses during component + tests, allowing the frontend to be tested independently of a running backend. + +The test files include: + +- `src/App.test.tsx` +- `src/graphql/queries.test.tsx` +- `src/lib/catalog.test.ts` + +The current frontend test suite contains 11 tests. + +Run the tests using: + +```bash +npm test +``` + +### 5.2 Linting + +ESLint is used to identify potential code quality issues and enforce consistent +coding practices. + +Run ESLint using: + +```bash +npm run lint +``` + +### 5.3 Code Formatting + +Prettier is configured using `.prettierrc.json`, with excluded directories defined +in `.prettierignore`. + +To automatically format the frontend source files and Vite configuration, run: + +```bash +npm run format +``` + +To check formatting without modifying files, run: + +```bash +npm run format:check +``` + +### 5.4 Production Build + +The frontend is built using TypeScript and Vite. + +To verify TypeScript compilation and generate the production build, run: + +```bash +npm run build +``` + +### 5.5 End-to-End Testing + +Playwright has been added as a development dependency, and the following command +is available: + +```bash +npm run test:e2e +``` + +The implementation and configuration of Playwright end-to-end tests are handled +separately as part of the team's testing responsibilities. + +The presence of the command does not mean that the end-to-end tests have already +been implemented or executed successfully. + +## 6. Development and Configuration + +### 6.1 Local Development + +The frontend uses Vite as its development server. + +After cloning the repository, install the project dependencies: + +```bash +npm install +``` + +Start the development server: + +```bash +npm run dev +``` + +The development server runs on: + +`http://localhost:5173` + +The Vite configuration uses port `5173` with `strictPort` enabled, meaning the server + will not automatically switch to another port if it is occupied. + +### 6.2 GraphQL Endpoint + +Apollo Client is configured in `src/lib/apollo.ts`. + +By default, the frontend sends GraphQL requests to `/graphql`. During local development, +Vite proxies these requests to: + +`http://127.0.0.1:3001` + +This allows the frontend and backend to run on separate local ports without requiring +the frontend to use the backend address directly. + +The GraphQL endpoint can be overridden using the `VITE_GRAPHQL_URL` environment variable. + +An `.env.example` file is provided to document this configuration. + +### 6.3 Production Build and Base Path + +The Vite configuration uses the following base path: + +`/project2/` + +This allows the frontend to be deployed under a subdirectory instead of the root +of a web server. + +The production build is generated using: + +```bash +npm run build +``` + +The generated files are placed in the `dist` directory. + +For deployment under `/project2/`, the GraphQL endpoint must be configured to match +the backend routing. For example, if the backend is exposed at `/project2/graphql`, +klset `VITE_GRAPHQL_URL=/project2/graphql` before building the frontend. + +The final deployment configuration and backend routing must be coordinated with the +team member responsible for deployment. + +### 6.4 Frontend and Backend Integration + +The frontend defines GraphQL queries and TypeScript types for the expected API responses. + +During frontend development, mocked GraphQL responses allow the interface and tests +to work independently of the backend. + +Integration with the running backend requires the backend schema to support the +expected queries, input parameters, and response fields. + +Once the backend is available, the frontend should be tested against real GraphQL +responses to verify that the agreed API contract is implemented correctly. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5de67a6..7a06d43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@eslint/js": "^9.35.0", + "@playwright/test": "^1.64.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -27,6 +28,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.9.10", "typescript": "~5.9.2", "typescript-eslint": "^8.42.0", "vite": "^7.1.3", @@ -1246,6 +1248,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3705,6 +3723,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -3744,6 +3791,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/package.json b/package.json index 53322fe..3c3ade7 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", - "build": "tsc -b && vite build", - "preview": "vite preview --host 0.0.0.0", - "lint": "eslint .", - "test": "vitest run" + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0", + "lint": "eslint .", + "test": "vitest run", + "format": "prettier --write \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", + "test:e2e": "playwright test" }, "dependencies": { "@apollo/client": "^4.3.3", @@ -19,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^9.35.0", + "@playwright/test": "^1.64.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -30,6 +34,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.9.10", "typescript": "~5.9.2", "typescript-eslint": "^8.42.0", "vite": "^7.1.3", diff --git a/src/App.test.tsx b/src/App.test.tsx index 9ea4f2b..1cb26ab 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,8 +1,18 @@ - import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import {cleanup, render, screen, waitFor, within,} from '@testing-library/react' +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { ApolloClient, InMemoryCache } from '@apollo/client' +import { + ApolloClient, + ApolloLink, + InMemoryCache, + Observable, +} from '@apollo/client' import { ApolloProvider } from '@apollo/client/react' import App from './App' import { createCatalogMockLink } from './graphql/mocks' @@ -11,9 +21,9 @@ beforeEach(() => localStorage.clear()) afterEach(cleanup) // Gir App en Apollo Client med simulerte GraphQL-svar. -function renderApp() { +function renderApp(link: ApolloLink = createCatalogMockLink()) { const client = new ApolloClient({ - link: createCatalogMockLink(), + link, cache: new InMemoryCache(), }) @@ -52,9 +62,7 @@ describe('Spillkveld', () => { screen.getByRole('searchbox', { name: 'Søk etter spill' }), ).toHaveValue('Azul') - expect( - screen.getByRole('button', { name: 'Azul' }), - ).toHaveFocus() + expect(screen.getByRole('button', { name: 'Azul' })).toHaveFocus() }) it('shows an actionable empty state', async () => { @@ -72,16 +80,13 @@ describe('Spillkveld', () => { }), ).toBeInTheDocument() - await user.click( - screen.getByRole('button', { name: 'Vis alle spill' }), - ) + await user.click(screen.getByRole('button', { name: 'Vis alle spill' })) await waitFor(() => { expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') }) }) - it('adds, persists and removes a game from the selection', async () => { const user = userEvent.setup() const view = renderApp() @@ -98,9 +103,7 @@ describe('Spillkveld', () => { }) // Vent på GraphQL-responsen. - expect( - await within(selection).findByText('Azul'), - ).toBeInTheDocument() + expect(await within(selection).findByText('Azul')).toBeInTheDocument() // Kontroller at ID-en er lagret. expect( @@ -116,9 +119,7 @@ describe('Spillkveld', () => { }) // Vent på at GraphQL henter det lagrede spillet. - expect( - await within(restored).findByText('Azul'), - ).toBeInTheDocument() + expect(await within(restored).findByText('Azul')).toBeInTheDocument() // Fjern Azul fra utvalget. await user.click( @@ -128,12 +129,9 @@ describe('Spillkveld', () => { ) await waitFor(() => { - expect( - within(restored).queryByText('Azul'), - ).not.toBeInTheDocument() + expect(within(restored).queryByText('Azul')).not.toBeInTheDocument() }) - expect( JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]'), ).toEqual([]) @@ -144,33 +142,25 @@ describe('Spillkveld', () => { renderApp() await waitFor(() => { - expect(screen.getByRole('status')).toHaveTextContent( - '12 spill funnet', - ) + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') }) expect(screen.getAllByText('Side 1 av 2').length).toBeGreaterThan(0) - await user.click( - screen.getByRole('button', { name: /Neste/ }), - ) + await user.click(screen.getByRole('button', { name: /Neste/ })) await waitFor(() => { expect(screen.getAllByText('Side 2 av 2').length).toBeGreaterThan(0) }) - expect( - await screen.findByRole('button', { name: /Forrige/ }), - ).toBeEnabled() + expect(await screen.findByRole('button', { name: /Forrige/ })).toBeEnabled() }) it('loads game details through GraphQL', async () => { const user = userEvent.setup() renderApp() - await user.click( - await screen.findByRole('button', { name: 'Azul' }), - ) + await user.click(await screen.findByRole('button', { name: 'Azul' })) expect( await screen.findByRole('heading', { @@ -193,5 +183,41 @@ describe('Spillkveld', () => { ).toBeGreaterThan(1) }) }) -}) + it('shows an error and retries loading games', async () => { + const user = userEvent.setup() + let attempts = 0 + + const retryLink = new ApolloLink((operation, forward) => { + if (operation.operationName === 'GetGames') { + attempts += 1 + + if (attempts === 1) { + return new Observable((observer) => { + observer.error(new Error('Simulert nettverksfeil')) + }) + } + } + + return forward(operation) + }) + + const link = ApolloLink.from([retryLink, createCatalogMockLink()]) + + renderApp(link) + + expect( + await screen.findByRole('heading', { + name: 'Kunne ikke hente spill', + }), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Prøv igjen' })) + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + }) + + expect(attempts).toBe(2) + }) +}) diff --git a/src/App.tsx b/src/App.tsx index e668ea1..468603a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,12 +3,17 @@ import type { Game } from './data/games' import { PAGE_SIZE, type Filters } from './lib/catalog' import { readSelection, saveSelection } from './lib/selection' import { useQuery } from '@apollo/client/react' -import { GET_CATEGORIES, GET_GAME, GET_GAMES, GET_GAMES_BY_IDS,} from './graphql/queries' +import { + GET_CATEGORIES, + GET_GAME, + GET_GAMES, + GET_GAMES_BY_IDS, +} from './graphql/queries' import type { CategoriesData, - GameData, - GameVariables, - GamesByIdsData, + GameData, + GameVariables, + GamesByIdsData, GamesByIdsVariables, GamesData, GamesVariables, @@ -71,73 +76,69 @@ export default function App() { const debouncedQuery = useDebouncedValue(filters.query, 300) - const { data, previousData, loading, error } = - useQuery( - GET_GAMES, - { - variables: { - input: { - query: debouncedQuery, - players: filters.players, - maxMinutes: filters.maxMinutes, - category: filters.category, - sort: filters.sort === 'name' ? 'NAME' : 'TIME', - page, - pageSize: PAGE_SIZE, - }, + const { data, previousData, loading, error, refetch } = useQuery< + GamesData, + GamesVariables + >(GET_GAMES, { + variables: { + input: { + query: debouncedQuery, + players: filters.players, + maxMinutes: filters.maxMinutes, + category: filters.category, + sort: filters.sort === 'name' ? 'NAME' : 'TIME', + page, + pageSize: PAGE_SIZE, }, }, - ) - -const { - data: categoriesData, - loading: categoriesLoading, - error: categoriesError, -} = useQuery(GET_CATEGORIES) + }) + + const { + data: categoriesData, + loading: categoriesLoading, + error: categoriesError, + } = useQuery(GET_CATEGORIES) + + const categories = categoriesData?.categories ?? [] -const categories = categoriesData?.categories ?? [] - -const [selectedId, setSelectedId] = useState(null) + const [selectedId, setSelectedId] = useState(null) const { data: detailData, loading: detailLoading, error: detailError, - } = useQuery(GET_GAME, { - variables: { id: selectedId ?? '' }, - skip: selectedId === null, + } = useQuery(GET_GAME, { + variables: { id: selectedId ?? '' }, + skip: selectedId === null, }) - -const [selection, setSelection] = useState(() => - readSelection(), -) - -const { data: selectionData, loading: selectionLoading, error: selectionError, -} = useQuery(GET_GAMES_BY_IDS, - { + + const [selection, setSelection] = useState(() => readSelection()) + + const { + data: selectionData, + loading: selectionLoading, + error: selectionError, + } = useQuery(GET_GAMES_BY_IDS, { variables: { ids: selection }, skip: selection.length === 0, - }, -) + }) const headingRef = useRef(null) const returningGameRef = useRef(null) - const totalCount = data?.games.totalCount ?? previousData?.games.totalCount ?? 0 + const totalCount = + data?.games.totalCount ?? previousData?.games.totalCount ?? 0 const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const visibleGames = data?.games.items ?? [] const detail = selectedId ? detailData?.game : null -const selectedGames = - selectionData?.gamesByIds ?? [] + const selectedGames = selectionData?.gamesByIds ?? [] useEffect(() => { if (selectedId) { headingRef.current?.focus() } else if (returningGameRef.current) { - document - .getElementById(`open-${returningGameRef.current}`) - ?.focus() + document.getElementById(`open-${returningGameRef.current}`)?.focus() returningGameRef.current = null } }, [selectedId]) @@ -195,72 +196,70 @@ const selectedGames =

{selectedId ? ( - detailLoading ? ( -
-

Laster spilldetaljer …

-
- ) : detailError || !detail ? ( -
-

Kunne ikke hente spilldetaljer

- -
- ) : ( -
- - -
- - -
- - {detail.category} · Eksempeldata - - -

- {detail.name} -

- -

- {detail.description} -

- -
-
-
Spillere
-
- {detail.minPlayers === detail.maxPlayers - ? detail.minPlayers - : `${detail.minPlayers}–${detail.maxPlayers}`} -
-
- -
-
Spilletid
-
Ca. {detail.minutes} min
-
- -
-
Kategori
-
{detail.category}
-
-
+ detailLoading ? ( +
+

Laster spilldetaljer …

+
+ ) : detailError || !detail ? ( +
+

Kunne ikke hente spilldetaljer

+ +
+ ) : ( +
+ + +
+ + +
+ + {detail.category} · Eksempeldata + - +

+ {detail.name} +

+ +

{detail.description}

+ +
+
+
Spillere
+
+ {detail.minPlayers === detail.maxPlayers + ? detail.minPlayers + : `${detail.minPlayers}–${detail.maxPlayers}`} +
+
+ +
+
Spilletid
+
Ca. {detail.minutes} min
+
+ +
+
Kategori
+
{detail.category}
+
+
+ + +
-
-
+
) ) : ( <> @@ -275,8 +274,8 @@ const selectedGames =

- Finn et spill som passer gjengen og tiden dere har. - Samle favorittene til kvelden på ett sted. + Finn et spill som passer gjengen og tiden dere har. Samle + favorittene til kvelden på ett sted.

@@ -294,9 +293,7 @@ const selectedGames =

Finn deres neste favoritt

- - Statiske eksempeldata - + Statiske eksempeldata
@@ -371,18 +368,18 @@ const selectedGames = updateFilters({ category: event.target.value }) } > - - {categories.map((category) => ( - - ))} + + {categories.map((category) => ( + + ))}
@@ -418,8 +415,8 @@ const selectedGames =

{totalCount === 1 - ? '1 spill funnet' - : `${totalCount} spill funnet`} + ? '1 spill funnet' + : `${totalCount} spill funnet`}

{totalCount > 0 && ( @@ -436,7 +433,15 @@ const selectedGames = ) : error ? (

Kunne ikke hente spill

-

Prøv igjen senere.

+

Noe gikk galt under henting av spill.

+
) : totalCount === 0 ? (
@@ -463,9 +468,7 @@ const selectedGames =
- - {game.category} - + {game.category}

@@ -523,9 +521,7 @@ const selectedGames = @@ -564,8 +560,8 @@ const selectedGames =

- Ingen spill lagt til ennå. Velg noen favoritter fra - katalogen over. + Ingen spill lagt til ennå. Velg noen favoritter fra katalogen + over.

) : ( diff --git a/src/data/games.ts b/src/data/games.ts index fd59ee5..64cc01e 100644 --- a/src/data/games.ts +++ b/src/data/games.ts @@ -13,16 +13,140 @@ export type Game = { } export const games: Game[] = [ - { id: 'azul', name: 'Azul', description: 'Legg fargerike fliser og bygg et mønster som gir flest poeng.', minPlayers: 2, maxPlayers: 4, minutes: 40, category: 'Strategi', icon: '▦', tone: 'blue' }, - { id: 'codenames', name: 'Codenames', description: 'Gi smarte ledetråder og finn lagets hemmelige ord.', minPlayers: 2, maxPlayers: 8, minutes: 20, category: 'Selskap', icon: '✳', tone: 'purple' }, - { id: 'carcassonne', name: 'Carcassonne', description: 'Bygg landskap med brikker og plasser følgere på byer og veier.', minPlayers: 2, maxPlayers: 5, minutes: 45, category: 'Strategi', icon: '⌂', tone: 'green' }, - { id: 'dixit', name: 'Dixit', description: 'Fortell en liten historie til et bilde og gjett hvilket kort som er riktig.', minPlayers: 3, maxPlayers: 6, minutes: 30, category: 'Kreativt', icon: '✦', tone: 'pink' }, - { id: 'ticket-to-ride', name: 'Ticket to Ride', description: 'Bygg togruter mellom byer og fullfør reisemålene dine.', minPlayers: 2, maxPlayers: 5, minutes: 60, category: 'Familie', icon: '↗', tone: 'orange' }, - { id: 'pandemic', name: 'Pandemic', description: 'Samarbeid om å stoppe utbrudd før tiden renner ut.', minPlayers: 2, maxPlayers: 4, minutes: 45, category: 'Samarbeid', icon: '✚', tone: 'teal' }, - { id: 'splendor', name: 'Splendor', description: 'Samle edelstener, kjøp kort og bygg en verdifull samling.', minPlayers: 2, maxPlayers: 4, minutes: 30, category: 'Strategi', icon: '◆', tone: 'purple' }, - { id: 'just-one', name: 'Just One', description: 'Skriv ett hint hver og hjelp en spiller å gjette ordet.', minPlayers: 3, maxPlayers: 7, minutes: 20, category: 'Samarbeid', icon: '☝', tone: 'yellow' }, - { id: 'kingdomino', name: 'Kingdomino', description: 'Velg dominobrikker og sett sammen ditt eget kongerike.', minPlayers: 2, maxPlayers: 4, minutes: 25, category: 'Familie', icon: '♛', tone: 'green' }, - { id: '7-wonders', name: '7 Wonders', description: 'Bygg en sivilisasjon med kort og samle ressurser over tre epoker.', minPlayers: 3, maxPlayers: 7, minutes: 30, category: 'Strategi', icon: '◭', tone: 'orange' }, - { id: 'the-crew', name: 'The Crew', description: 'Løs oppdrag sammen ved å spille kort uten å si for mye.', minPlayers: 3, maxPlayers: 5, minutes: 20, category: 'Samarbeid', icon: '✧', tone: 'blue' }, - { id: 'patchwork', name: 'Patchwork', description: 'Sy sammen et lappeteppe ved å velge og plassere brikker.', minPlayers: 2, maxPlayers: 2, minutes: 30, category: 'Strategi', icon: '▧', tone: 'pink' }, + { + id: 'azul', + name: 'Azul', + description: + 'Legg fargerike fliser og bygg et mønster som gir flest poeng.', + minPlayers: 2, + maxPlayers: 4, + minutes: 40, + category: 'Strategi', + icon: '▦', + tone: 'blue', + }, + { + id: 'codenames', + name: 'Codenames', + description: 'Gi smarte ledetråder og finn lagets hemmelige ord.', + minPlayers: 2, + maxPlayers: 8, + minutes: 20, + category: 'Selskap', + icon: '✳', + tone: 'purple', + }, + { + id: 'carcassonne', + name: 'Carcassonne', + description: + 'Bygg landskap med brikker og plasser følgere på byer og veier.', + minPlayers: 2, + maxPlayers: 5, + minutes: 45, + category: 'Strategi', + icon: '⌂', + tone: 'green', + }, + { + id: 'dixit', + name: 'Dixit', + description: + 'Fortell en liten historie til et bilde og gjett hvilket kort som er riktig.', + minPlayers: 3, + maxPlayers: 6, + minutes: 30, + category: 'Kreativt', + icon: '✦', + tone: 'pink', + }, + { + id: 'ticket-to-ride', + name: 'Ticket to Ride', + description: 'Bygg togruter mellom byer og fullfør reisemålene dine.', + minPlayers: 2, + maxPlayers: 5, + minutes: 60, + category: 'Familie', + icon: '↗', + tone: 'orange', + }, + { + id: 'pandemic', + name: 'Pandemic', + description: 'Samarbeid om å stoppe utbrudd før tiden renner ut.', + minPlayers: 2, + maxPlayers: 4, + minutes: 45, + category: 'Samarbeid', + icon: '✚', + tone: 'teal', + }, + { + id: 'splendor', + name: 'Splendor', + description: 'Samle edelstener, kjøp kort og bygg en verdifull samling.', + minPlayers: 2, + maxPlayers: 4, + minutes: 30, + category: 'Strategi', + icon: '◆', + tone: 'purple', + }, + { + id: 'just-one', + name: 'Just One', + description: 'Skriv ett hint hver og hjelp en spiller å gjette ordet.', + minPlayers: 3, + maxPlayers: 7, + minutes: 20, + category: 'Samarbeid', + icon: '☝', + tone: 'yellow', + }, + { + id: 'kingdomino', + name: 'Kingdomino', + description: 'Velg dominobrikker og sett sammen ditt eget kongerike.', + minPlayers: 2, + maxPlayers: 4, + minutes: 25, + category: 'Familie', + icon: '♛', + tone: 'green', + }, + { + id: '7-wonders', + name: '7 Wonders', + description: + 'Bygg en sivilisasjon med kort og samle ressurser over tre epoker.', + minPlayers: 3, + maxPlayers: 7, + minutes: 30, + category: 'Strategi', + icon: '◭', + tone: 'orange', + }, + { + id: 'the-crew', + name: 'The Crew', + description: 'Løs oppdrag sammen ved å spille kort uten å si for mye.', + minPlayers: 3, + maxPlayers: 5, + minutes: 20, + category: 'Samarbeid', + icon: '✧', + tone: 'blue', + }, + { + id: 'patchwork', + name: 'Patchwork', + description: 'Sy sammen et lappeteppe ved å velge og plassere brikker.', + minPlayers: 2, + maxPlayers: 2, + minutes: 30, + category: 'Strategi', + icon: '▧', + tone: 'pink', + }, ] diff --git a/src/graphql/mocks.ts b/src/graphql/mocks.ts index 900d508..241a3b6 100644 --- a/src/graphql/mocks.ts +++ b/src/graphql/mocks.ts @@ -1,6 +1,11 @@ import { MockLink } from '@apollo/client/testing' import { games } from '../data/games' -import { GET_CATEGORIES, GET_GAME, GET_GAMES, GET_GAMES_BY_IDS,} from './queries' +import { + GET_CATEGORIES, + GET_GAME, + GET_GAMES, + GET_GAMES_BY_IDS, +} from './queries' import type { CatalogInput } from './types' import { filterGames, PAGE_SIZE } from '../lib/catalog' @@ -26,8 +31,6 @@ function getCatalogResult(input: CatalogInput) { } } - - export function createCatalogMockLink() { return new MockLink([ { @@ -54,9 +57,7 @@ export function createCatalogMockLink() { }, }, result: (variables) => { - const game = games.find( - (item) => item.id === variables.id, - ) + const game = games.find((item) => item.id === variables.id) return { data: { @@ -101,6 +102,3 @@ export function createCatalogMockLink() { }, ]) } - - - diff --git a/src/graphql/queries.test.tsx b/src/graphql/queries.test.tsx index 77a8187..fbb9043 100644 --- a/src/graphql/queries.test.tsx +++ b/src/graphql/queries.test.tsx @@ -72,4 +72,4 @@ describe('GET_GAMES', () => { expect(await screen.findByText('Azul')).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 6777cda..48acf33 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -56,4 +56,4 @@ export const GET_CATEGORIES = gql` query GetCategories { categories } -` \ No newline at end of file +` diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 8719f35..4259c0d 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -43,4 +43,4 @@ export type GamesByIdsVariables = { export type CategoriesData = { categories: string[] -} \ No newline at end of file +} diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts index 599351d..8e2e826 100644 --- a/src/lib/catalog.test.ts +++ b/src/lib/catalog.test.ts @@ -2,16 +2,33 @@ import { describe, expect, it } from 'vitest' import { games } from '../data/games' import { filterGames, pageGames, PAGE_SIZE, type Filters } from './catalog' -const defaults: Filters = { query: '', players: null, maxMinutes: null, category: '', sort: 'name' } +const defaults: Filters = { + query: '', + players: null, + maxMinutes: null, + category: '', + sort: 'name', +} describe('catalog', () => { it('searches title and description without case sensitivity', () => { - expect(filterGames(games, { ...defaults, query: 'AZUL' }).map((game) => game.id)).toEqual(['azul']) - expect(filterGames(games, { ...defaults, query: 'utbrudd' }).map((game) => game.id)).toEqual(['pandemic']) + expect( + filterGames(games, { ...defaults, query: 'AZUL' }).map((game) => game.id), + ).toEqual(['azul']) + expect( + filterGames(games, { ...defaults, query: 'utbrudd' }).map( + (game) => game.id, + ), + ).toEqual(['pandemic']) }) it('combines player, time and category filters', () => { - const result = filterGames(games, { ...defaults, players: 4, maxMinutes: 30, category: 'Samarbeid' }) + const result = filterGames(games, { + ...defaults, + players: 4, + maxMinutes: 30, + category: 'Samarbeid', + }) expect(result.map((game) => game.id)).toEqual(['just-one', 'the-crew']) }) @@ -21,6 +38,12 @@ describe('catalog', () => { expect(result[1].name).toBe('Just One') expect(pageGames(result, 1)).toHaveLength(PAGE_SIZE) expect(pageGames(result, 2)).toHaveLength(games.length - PAGE_SIZE) - expect(new Set([...pageGames(result, 1), ...pageGames(result, 2)].map((game) => game.id)).size).toBe(games.length) + expect( + new Set( + [...pageGames(result, 1), ...pageGames(result, 2)].map( + (game) => game.id, + ), + ).size, + ).toBe(games.length) }) }) diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts index a4d0d44..626ccec 100644 --- a/src/lib/catalog.ts +++ b/src/lib/catalog.ts @@ -15,15 +15,26 @@ export function filterGames(games: Game[], filters: Filters): Game[] { return games .filter((game) => { - const matchesQuery = !query || `${game.name} ${game.description}`.toLocaleLowerCase('no').includes(query) - const matchesPlayers = filters.players === null || (game.minPlayers <= filters.players && game.maxPlayers >= filters.players) - const matchesTime = filters.maxMinutes === null || game.minutes <= filters.maxMinutes - const matchesCategory = !filters.category || game.category === filters.category + const matchesQuery = + !query || + `${game.name} ${game.description}` + .toLocaleLowerCase('no') + .includes(query) + const matchesPlayers = + filters.players === null || + (game.minPlayers <= filters.players && + game.maxPlayers >= filters.players) + const matchesTime = + filters.maxMinutes === null || game.minutes <= filters.maxMinutes + const matchesCategory = + !filters.category || game.category === filters.category return matchesQuery && matchesPlayers && matchesTime && matchesCategory }) - .sort((a, b) => filters.sort === 'minutes' - ? a.minutes - b.minutes || a.name.localeCompare(b.name, 'no') - : a.name.localeCompare(b.name, 'no')) + .sort((a, b) => + filters.sort === 'minutes' + ? a.minutes - b.minutes || a.name.localeCompare(b.name, 'no') + : a.name.localeCompare(b.name, 'no'), + ) } export function pageGames(games: Game[], page: number): Game[] { diff --git a/src/lib/selection.ts b/src/lib/selection.ts index 89592fd..44906a2 100644 --- a/src/lib/selection.ts +++ b/src/lib/selection.ts @@ -1,4 +1,3 @@ - const STORAGE_KEY = 'spillkveld-selection-v1' export function readSelection(): string[] { @@ -12,8 +11,7 @@ export function readSelection(): string[] { return [ ...new Set( saved.filter( - (id): id is string => - typeof id === 'string' && id.length > 0, + (id): id is string => typeof id === 'string' && id.length > 0, ), ), ] @@ -29,4 +27,3 @@ export function saveSelection(ids: string[]): void { // Appen fungerer fortsatt når nettleseren blokkerer lagring. } } - diff --git a/src/main.tsx b/src/main.tsx index ab36293..0038eec 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,4 +12,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render( , ) - diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 151aa68..11f02fe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +/// diff --git a/vite.config.ts b/vite.config.ts index cb8b5b8..28af35b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + base: '/project2/', server: { port: 5173, From 5d120b261fcd9aecc98908ddfa85c24042694313 Mon Sep 17 00:00:00 2001 From: Andreas Date: Mon, 12 Oct 2026 19:56:51 +0200 Subject: [PATCH 4/6] Upgrade Vitest to resolve security vulnerabilities --- package-lock.json | 399 ++++++++++++---------------------------------- package.json | 18 +-- 2 files changed, 109 insertions(+), 308 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a06d43..1d9cf3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "typescript": "~5.9.2", "typescript-eslint": "^8.42.0", "vite": "^7.1.3", - "vitest": "^3.2.4" + "vitest": "^5.0.4" } }, "node_modules/@adobe/css-tools": { @@ -2142,40 +2142,24 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2186,73 +2170,12 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -2474,16 +2397,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2516,18 +2429,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2549,16 +2455,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2675,16 +2571,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2731,9 +2617,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -3467,13 +3353,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3496,13 +3375,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/min-indent": { @@ -3578,6 +3457,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optimism": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", @@ -3686,23 +3579,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4031,13 +3907,6 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -4048,17 +3917,10 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -4088,26 +3950,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4129,18 +3971,24 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -4159,36 +4007,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -4428,89 +4246,73 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4521,6 +4323,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -4602,20 +4407,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { diff --git a/package.json b/package.json index 3c3ade7..241d09c 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", - "build": "tsc -b && vite build", - "preview": "vite preview --host 0.0.0.0", - "lint": "eslint .", - "test": "vitest run", - "format": "prettier --write \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", - "test:e2e": "playwright test" + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0", + "lint": "eslint .", + "test": "vitest run", + "format": "prettier --write \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", + "test:e2e": "playwright test" }, "dependencies": { "@apollo/client": "^4.3.3", @@ -38,6 +38,6 @@ "typescript": "~5.9.2", "typescript-eslint": "^8.42.0", "vite": "^7.1.3", - "vitest": "^3.2.4" + "vitest": "^5.0.4" } } From eba6445f8bbe6600a4250f3cb8c4d28be2b46ac4 Mon Sep 17 00:00:00 2001 From: Andreas Date: Tue, 13 Oct 2026 14:42:40 +0200 Subject: [PATCH 5/6] Address PR review feedback on focus, missing games and AI documentation --- docs/frontend.md | 14 ++++++++++++- src/App.test.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 23 ++++++++++++++------- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/frontend.md b/docs/frontend.md index 7ca3219..680682e 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -298,4 +298,16 @@ Integration with the running backend requires the backend schema to support the expected queries, input parameters, and response fields. Once the backend is available, the frontend should be tested against real GraphQL -responses to verify that the agreed API contract is implemented correctly. \ No newline at end of file +responses to verify that the agreed API contract is implemented correctly. + +## AI Usage Declaration + +ChatGPT was used as a supporting tool during frontend development, mainly for +discussing implementation approaches, troubleshooting, reviewing code, and +suggesting improvements. + +AI was also used to help with React, TypeScript, Apollo Client, GraphQL +integration, automated tests, and documentation. The suggested solutions were +reviewed, adjusted where necessary, and tested to ensure they worked as +intended. All changes were verified using automated tests, ESLint, and +production builds. \ No newline at end of file diff --git a/src/App.test.tsx b/src/App.test.tsx index 1cb26ab..6607433 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -170,6 +170,59 @@ describe('Spillkveld', () => { ).toBeInTheDocument() }) + it('focuses the game heading after details have loaded', async () => { + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: 'Azul' })) + + const heading = await screen.findByRole('heading', { + name: 'Azul', + level: 1, + }) + + await waitFor(() => { + expect(heading).toHaveFocus() + }) + }) + + it('shows a not found message when GraphQL returns game null', async () => { + const user = userEvent.setup() + + const notFoundLink = new ApolloLink((operation, forward) => { + if (operation.operationName === 'GetGame') { + return new Observable((observer) => { + observer.next({ data: { game: null } }) + observer.complete() + }) + } + + return forward(operation) + }) + + renderApp(ApolloLink.from([notFoundLink, createCatalogMockLink()])) + + await user.click(await screen.findByRole('button', { name: 'Azul' })) + + expect( + await screen.findByRole('heading', { + name: 'Spillet ble ikke funnet', + }), + ).toBeInTheDocument() + + expect( + screen.queryByRole('heading', { + name: 'Kunne ikke hente spilldetaljer', + }), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: /Tilbake til resultatene/ }), + ) + + expect(await screen.findByRole('button', { name: 'Azul' })).toBeInTheDocument() + }) + it('loads categories through GraphQL', async () => { renderApp() diff --git a/src/App.tsx b/src/App.tsx index 468603a..605232d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -134,14 +134,16 @@ export default function App() { const selectedGames = selectionData?.gamesByIds ?? [] - useEffect(() => { - if (selectedId) { +useEffect(() => { + if (selectedId) { + if (!detailLoading && detail) { headingRef.current?.focus() - } else if (returningGameRef.current) { - document.getElementById(`open-${returningGameRef.current}`)?.focus() - returningGameRef.current = null } - }, [selectedId]) + } else if (returningGameRef.current) { + document.getElementById(`open-${returningGameRef.current}`)?.focus() + returningGameRef.current = null + } +}, [selectedId, detailLoading, detail]) function updateFilters(patch: Partial) { setFilters((current) => ({ ...current, ...patch })) @@ -200,13 +202,20 @@ export default function App() {

Laster spilldetaljer …

- ) : detailError || !detail ? ( + ) : detailError ? (

Kunne ikke hente spilldetaljer

+ ) : !detail ? ( +
+

Spillet ble ikke funnet

+ +
) : (