From 6d27cd446c88c9aa212423b7e76a04e42050f160 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:47:59 +0200 Subject: [PATCH 1/7] build(config): bruk /project2/ som base og proxy API-et lokalt Co-authored-by: birkjr --- vite.config.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 18f31aa..6f17882 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,8 +2,24 @@ import react from '@vitejs/plugin-react'; // defineConfig fra vitest/config godtar også test-innstillingene under. import { defineConfig } from 'vitest/config'; +/** Adressen til GraphQL-backenden ved lokal utvikling (backend/.env). */ +const BACKEND_URL = 'http://localhost:3001'; + export default defineConfig({ + // Appen publiseres under /project2 på VM-en. Samme base lokalt gjør at + // lenker, bilder og API-kall bruker de samme stiene i utvikling og drift. + base: '/project2/', plugins: [react()], + server: { + proxy: { + // På VM-en sender Apache /project2/api/ videre til backenden. Lokalt + // gjør Vite det samme, så frontend slipper å kjenne til portnummeret. + '/project2/api': { + target: BACKEND_URL, + rewrite: (path) => path.replace(/^\/project2\/api/, ''), + }, + }, + }, test: { // jsdom gir et DOM i Node, slik at komponentene kan rendres uten nettleser. environment: 'jsdom', From 6b6b59bc8c27c394e8d50a1dee9be894d2942b05 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:48:40 +0200 Subject: [PATCH 2/7] refactor(src): flytt annonsebildene til public Co-authored-by: andretg --- README.md | 6 +-- backend/src/seed/listingImageUrl.ts | 2 +- backend/src/seed/listingTexts.ts | 2 +- deploy/apache/README.md | 44 +++++++++---------- deploy/apache/t36-project2.conf | 13 +----- docs/vm-backend.md | 1 - {src => public}/assets/listings/drill.svg | 0 {src => public}/assets/listings/handsaw.svg | 0 .../assets/listings/paint-roller.svg | 0 {src => public}/assets/listings/spade.svg | 0 src/data/mockListings.ts | 16 +++++-- 11 files changed, 40 insertions(+), 44 deletions(-) rename {src => public}/assets/listings/drill.svg (100%) rename {src => public}/assets/listings/handsaw.svg (100%) rename {src => public}/assets/listings/paint-roller.svg (100%) rename {src => public}/assets/listings/spade.svg (100%) diff --git a/README.md b/README.md index 3a55caf..c5b53bb 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,9 @@ Forvent **10 010** rader i `listings` etter første kjøring, og «0 nye» om du kjører den på nytt. Tar under ett sekund for 10 000 annonser. Datakilde, fordelinger og målinger står i [docs/datasett.md](docs/datasett.md). -**Bilder og deploy:** Seed lagrer `image_url` under `/project2/assets/listings/`. -Det gjelder VM/Apache senere — se [deploy/apache/README.md](deploy/apache/README.md). -Lokal frontend bruker fortsatt mock og importerte SVG-er. +**Bilder:** Seed lagrer `image_url` under `/project2/assets/listings/`. Filene +ligger i `public/assets/listings/`, så Vite serverer dem på samme sti både ved +lokal utvikling og i bygget. ### GraphQL lokalt diff --git a/backend/src/seed/listingImageUrl.ts b/backend/src/seed/listingImageUrl.ts index 531f8f7..120cb7d 100644 --- a/backend/src/seed/listingImageUrl.ts +++ b/backend/src/seed/listingImageUrl.ts @@ -1,6 +1,6 @@ /** * Offentlig bildesti for annonser etter deploy under /project2. - * Filnavn tilsvarer SVG-ene i frontend src/assets/listings/. + * Filnavn tilsvarer SVG-ene i frontend public/assets/listings/. */ const DEFAULT_APP_BASE = '/project2'; diff --git a/backend/src/seed/listingTexts.ts b/backend/src/seed/listingTexts.ts index 4302656..29a3c5a 100644 --- a/backend/src/seed/listingTexts.ts +++ b/backend/src/seed/listingTexts.ts @@ -524,7 +524,7 @@ export const manualOnlyBrands: ReadonlySet = new Set([ ]); /** - * Bilder som finnes i frontend (src/assets/listings). De brukes bare på + * Bilder som finnes i frontend (public/assets/listings). De brukes bare på * verktøytyper bildet faktisk viser, slik at en vinkelsliper ikke får bilde av * en drill. Alle andre annonser vises med plassholder. */ diff --git a/deploy/apache/README.md b/deploy/apache/README.md index 8f54dcf..1951a68 100644 --- a/deploy/apache/README.md +++ b/deploy/apache/README.md @@ -6,13 +6,12 @@ Apache-oppsett. **Ikke aktiver på VM ennå** — krever bygget frontend med rik ## Vite: base path og statiske filer -| Tema | Status i repo nå | Krav ved VM-deploy | -| ------------------------ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `base` | Standard `/` i `vite.config.ts` | Sett `base: '/project2/'` i eget frontend-issue før produksjonsbuild | -| React Router | `basename={import.meta.env.BASE_URL}` i `main.tsx` | Følger automatisk Vite `base` | -| JS/CSS fra build | Hashede filer under `dist/assets/` | URL-er blir `/project2/assets/…` når `base` er satt | -| SVG i mock | Importert i bundle | Fungerer i dagens prototype | -| SVG i seed (`image_url`) | `/project2/assets/listings/*.svg` | **Ikke** fra Vite-build — egen Apache-`Alias` i `t36-project2.conf` mot `src/assets/listings/` | +| Tema | Status i repo nå | Krav ved VM-deploy | +| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `base` | `/project2/` i `vite.config.ts` | Ingen endring | +| React Router | `basename={import.meta.env.BASE_URL}` i `main.tsx` | Følger automatisk Vite `base` | +| JS/CSS fra build | Hashede filer under `dist/assets/` | URL-er blir `/project2/assets/…` når `base` er satt | +| Annonsebilder | `public/assets/listings/*.svg` | Blir med i bygget under `/project2/assets/listings/`, samme sti som `image_url` i databasen | Bygg (etter `base` er satt): @@ -48,9 +47,9 @@ Valgfritt senere: `headers` for cache på statiske filer. ## Filer -| Fil | Formål | -| ------------------- | -------------------------------------------------- | -| `t36-project2.conf` | Egen site: statisk SPA, listing-SVG, reverse proxy | +| Fil | Formål | +| ------------------- | --------------------------------------- | +| `t36-project2.conf` | Egen site: statisk SPA og reverse proxy | Plassholdere: @@ -82,13 +81,12 @@ Uten `mod_rewrite` vil direkte navigering til f.eks. `/project2/annonser/listing gi 404 fra Apache. `RewriteRule` i `t36-project2.conf` sender ukjente stier til `/project2/index.html` når fil/mappe ikke finnes. -## Listing-SVG (seed) +## Annonsebilder -Seed lagrer `image_url` som `/project2/assets/listings/drill.svg` osv. -**Mer spesifikk** `Alias` for `/project2/assets/listings` kommer **før** generell -`Alias /project2`, slik at SVG ikke overskygges av SPA-fallback. - -Filene ligger i repo: `src/assets/listings/*.svg` (samme navn som i seed). +Seed lagrer `image_url` som `/project2/assets/listings/drill.svg` osv. Filene +ligger i `public/assets/listings/`, så Vite kopierer dem til +`dist/assets/listings/`, og de serveres av samme `Alias /project2` som resten +av appen. Det trengs ingen egen `Alias` for bildene. ## Installasjon på VM (når klart — ikke kjør enable før dere er klare) @@ -139,10 +137,10 @@ ikke gi Apache 404. ## Feilsøking -| Symptom | Mulig årsak | -| ---------------------------------- | --------------------------------------------------- | -| `configtest` feiler på `ProxyPass` | `proxy` / `proxy_http` ikke enabled | -| SPA 404 ved refresh | `rewrite` ikke enabled, eller feil `RewriteBase` | -| SVG 404 | Feil `__REPO_ROOT__`, eller listing-`Alias` mangler | -| API 502/503 | Backend ikke startet på 3001 | -| `/project2` ødelegger P1 | Feil site aktivert — bruk kun `t36-project2.conf` | +| Symptom | Mulig årsak | +| ---------------------------------- | ------------------------------------------------- | +| `configtest` feiler på `ProxyPass` | `proxy` / `proxy_http` ikke enabled | +| SPA 404 ved refresh | `rewrite` ikke enabled, eller feil `RewriteBase` | +| SVG 404 | Frontend er bygget uten `public/assets/listings` | +| API 502/503 | Backend ikke startet på 3001 | +| `/project2` ødelegger P1 | Feil site aktivert — bruk kun `t36-project2.conf` | diff --git a/deploy/apache/t36-project2.conf b/deploy/apache/t36-project2.conf index ade35a8..e5153bd 100644 --- a/deploy/apache/t36-project2.conf +++ b/deploy/apache/t36-project2.conf @@ -7,17 +7,8 @@ # # Ikke rediger prosjekt 1s vhost. Aktiver med a2ensite t36-project2.conf. -# Seed/API-bilder: løse SVG-er (Vite bygger dem ikke ut til denne stien) - - Alias /project2/assets/listings __REPO_ROOT__/src/assets/listings - - - - Require all granted - Options -Indexes - - -# Frontend (Vite build med base: '/project2/') +# Frontend (Vite build med base: '/project2/'). Annonsebildene ligger i +# public/assets/listings og blir med i bygget under /project2/assets/listings. Alias /project2 __DIST_DIR__ diff --git a/docs/vm-backend.md b/docs/vm-backend.md index 288a727..98092b1 100644 --- a/docs/vm-backend.md +++ b/docs/vm-backend.md @@ -415,7 +415,6 @@ Egen site-mal (prosjekt 2 only) ligger i [deploy/apache/README.md](../deploy/apache/README.md): - Statisk frontend fra `dist/` med SPA-fallback -- `Alias` for listing-SVG under `/project2/assets/listings/` - Reverse proxy `/project2/api/` → `http://127.0.0.1:3001/` Krever `a2enmod alias rewrite proxy proxy_http` og **`apache2ctl configtest`** diff --git a/src/assets/listings/drill.svg b/public/assets/listings/drill.svg similarity index 100% rename from src/assets/listings/drill.svg rename to public/assets/listings/drill.svg diff --git a/src/assets/listings/handsaw.svg b/public/assets/listings/handsaw.svg similarity index 100% rename from src/assets/listings/handsaw.svg rename to public/assets/listings/handsaw.svg diff --git a/src/assets/listings/paint-roller.svg b/public/assets/listings/paint-roller.svg similarity index 100% rename from src/assets/listings/paint-roller.svg rename to public/assets/listings/paint-roller.svg diff --git a/src/assets/listings/spade.svg b/public/assets/listings/spade.svg similarity index 100% rename from src/assets/listings/spade.svg rename to public/assets/listings/spade.svg diff --git a/src/data/mockListings.ts b/src/data/mockListings.ts index be58e37..19abe72 100644 --- a/src/data/mockListings.ts +++ b/src/data/mockListings.ts @@ -10,12 +10,20 @@ * annonser gjennom props i stedet for å importere denne filen direkte. */ -import drillImage from '../assets/listings/drill.svg'; -import handsawImage from '../assets/listings/handsaw.svg'; -import paintRollerImage from '../assets/listings/paint-roller.svg'; -import spadeImage from '../assets/listings/spade.svg'; import type { Listing } from '../types/listing.ts'; +/** + * Bildene ligger i public/assets/listings, slik at de har samme adresse som + * image_url i databasen, for eksempel /project2/assets/listings/drill.svg. + */ +const imageUrl = (filename: string) => + `${import.meta.env.BASE_URL}assets/listings/${filename}`; + +const drillImage = imageUrl('drill.svg'); +const handsawImage = imageUrl('handsaw.svg'); +const paintRollerImage = imageUrl('paint-roller.svg'); +const spadeImage = imageUrl('spade.svg'); + export const mockListings: Listing[] = [ { id: 'listing-001', From 22f52ed3552d86810e97d064e9d2284ed1ef58f8 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:49:24 +0200 Subject: [PATCH 3/7] build(config): sett opp Apollo Client mot GraphQL-backenden Co-authored-by: tordtst --- package-lock.json | 150 +++++++++++++++++++++++++++++++++- package.json | 5 +- src/graphql/client.ts | 17 ++++ src/graphql/listingQueries.ts | 68 +++++++++++++++ src/main.tsx | 14 ++-- src/types/listing.ts | 6 ++ 6 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 src/graphql/client.ts create mode 100644 src/graphql/listingQueries.ts diff --git a/package-lock.json b/package-lock.json index 5ba7022..a80c6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "t36-verktoyutlan", "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -45,6 +48,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": "7.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", @@ -671,6 +711,15 @@ } } }, + "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", @@ -1564,6 +1613,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", @@ -2323,6 +2420,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/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2958,6 +3079,18 @@ "node": ">=12.20.0" } }, + "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", @@ -3269,6 +3402,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "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/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3447,6 +3589,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 0155037..bfa98ad 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,12 @@ "format:check": "node scripts/format-check.mjs" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/graphql/client.ts b/src/graphql/client.ts new file mode 100644 index 0000000..8050109 --- /dev/null +++ b/src/graphql/client.ts @@ -0,0 +1,17 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; + +/** + * GraphQL-endepunktet ligger under appens egen sti. På VM-en sender Apache + * /project2/api/ videre til backenden, og lokalt gjør Vite det samme (se + * vite.config.ts). Derfor trenger klienten ikke vite om portnummeret. + */ +export const GRAPHQL_URL = `${import.meta.env.BASE_URL}api/graphql`; + +/** + * Felles Apollo-klient. Cachen gjør at en side eller annonse som allerede er + * hentet, vises med en gang og uten ny forespørsel når brukeren går tilbake. + */ +export const apolloClient = new ApolloClient({ + link: new HttpLink({ uri: GRAPHQL_URL }), + cache: new InMemoryCache(), +}); diff --git a/src/graphql/listingQueries.ts b/src/graphql/listingQueries.ts new file mode 100644 index 0000000..b2668ca --- /dev/null +++ b/src/graphql/listingQueries.ts @@ -0,0 +1,68 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; +import type { Listing, ListingSummary } from '../types/listing.ts'; + +/** Antall annonser per side i katalogen. Serveren tillater maks 100. */ +export const CATALOG_PAGE_SIZE = 24; + +export interface ListingsPageData { + listings: { + items: ListingSummary[]; + totalCount: number; + hasNextPage: boolean; + }; +} + +export interface ListingsPageVariables { + limit: number; + offset: number; +} + +/** + * Én side i katalogen. Henter bare feltene annonsekortet viser, så hver side + * blir liten. Resten hentes først når brukeren åpner en annonse. + */ +export const LISTINGS_PAGE_QUERY: TypedDocumentNode< + ListingsPageData, + ListingsPageVariables +> = gql` + query ListingsPage($limit: Int!, $offset: Int!) { + listings(limit: $limit, offset: $offset) { + items { + id + title + category + area + dailyPrice + imageUrl + } + totalCount + hasNextPage + } + } +`; + +export interface ListingData { + listing: Listing | null; +} + +export interface ListingVariables { + id: string; +} + +/** Alle opplysninger om én annonse, til detaljvisningen. */ +export const LISTING_QUERY: TypedDocumentNode = + gql` + query Listing($id: ID!) { + listing(id: $id) { + id + title + category + description + area + dailyPrice + condition + accessories + imageUrl + } + } + `; diff --git a/src/main.tsx b/src/main.tsx index 0fdc649..6b17787 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,9 @@ +import { ApolloProvider } from '@apollo/client/react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import App from './App.tsx'; +import { apolloClient } from './graphql/client.ts'; import './styles/tokens.css'; import './styles/base.css'; @@ -12,12 +14,14 @@ if (!rootElement) { throw new Error('Fant ikke #root i index.html'); } -// basename følger Vites base-sti, slik at rutene også virker når appen senere -// publiseres under en undermappe som /project2. +// basename følger Vites base-sti (/project2/), slik at rutene virker likt +// lokalt og på VM-en. createRoot(rootElement).render( - - - + + + + + , ); diff --git a/src/types/listing.ts b/src/types/listing.ts index b8c5abb..b2ec889 100644 --- a/src/types/listing.ts +++ b/src/types/listing.ts @@ -26,3 +26,9 @@ export interface Listing { /** Adresse til bilde av verktøyet, eller null når annonsen mangler bilde. */ imageUrl: string | null; } + +/** Feltene annonsekortet i katalogen trenger. */ +export type ListingSummary = Pick< + Listing, + 'id' | 'title' | 'category' | 'area' | 'dailyPrice' | 'imageUrl' +>; From 95a84d92228c1b0f86e829722e8a044cee384ebd Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:49:53 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat(src):=20lag=20komponent=20for=20=C3=A5?= =?UTF-8?q?=20bla=20mellom=20sider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: birkjr --- .../Pagination/Pagination.module.css | 42 ++++++++++++++ src/components/Pagination/Pagination.test.tsx | 55 +++++++++++++++++++ src/components/Pagination/Pagination.tsx | 54 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 src/components/Pagination/Pagination.module.css create mode 100644 src/components/Pagination/Pagination.test.tsx create mode 100644 src/components/Pagination/Pagination.tsx diff --git a/src/components/Pagination/Pagination.module.css b/src/components/Pagination/Pagination.module.css new file mode 100644 index 0000000..ba3eea0 --- /dev/null +++ b/src/components/Pagination/Pagination.module.css @@ -0,0 +1,42 @@ +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-2) var(--space-4); + margin-top: var(--space-8); +} + +/* Samme utseende som Button med variant="secondary". */ +.link, +.disabled { + display: inline-flex; + align-items: center; + min-height: var(--tap-target); + padding: var(--space-2) var(--space-6); + border: 2px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-weight: var(--font-weight-bold); + text-decoration: none; +} + +.link { + background: var(--color-surface); + color: var(--color-text); +} + +.link:hover { + background: var(--color-surface-muted); + color: var(--color-text); +} + +.disabled { + border-color: var(--color-border); + background: var(--color-surface-muted); + color: var(--color-text-muted); +} + +.status { + margin: 0; + color: var(--color-text-muted); +} diff --git a/src/components/Pagination/Pagination.test.tsx b/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 0000000..36c4040 --- /dev/null +++ b/src/components/Pagination/Pagination.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import Pagination from './Pagination.tsx'; + +function renderPagination(page: number, totalPages: number) { + return render( + + `?side=${target}`} + /> + , + ); +} + +describe('Pagination', () => { + it('lenker til forrige og neste side', () => { + renderPagination(3, 5); + expect(screen.getByRole('link', { name: 'Forrige' })).toHaveAttribute( + 'href', + '/?side=2', + ); + expect(screen.getByRole('link', { name: 'Neste' })).toHaveAttribute( + 'href', + '/?side=4', + ); + expect(screen.getByText('Side 3 av 5')).toBeInTheDocument(); + }); + + it('har ingen lenke bakover på første side', () => { + renderPagination(1, 5); + expect( + screen.queryByRole('link', { name: 'Forrige' }), + ).not.toBeInTheDocument(); + expect(screen.getByText('Forrige')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(screen.getByRole('link', { name: 'Neste' })).toBeInTheDocument(); + }); + + it('har ingen lenke framover på siste side', () => { + renderPagination(5, 5); + expect( + screen.queryByRole('link', { name: 'Neste' }), + ).not.toBeInTheDocument(); + }); + + it('vises ikke når alt får plass på én side', () => { + renderPagination(1, 1); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..faf06f8 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,54 @@ +import { Link } from 'react-router'; +import styles from './Pagination.module.css'; + +interface PaginationProps { + /** Gjeldende side, fra 1. */ + page: number; + totalPages: number; + /** Lager lenken til en side, for eksempel «?side=3». */ + pageLink: (page: number) => string; +} + +/** + * «Forrige» og «Neste» mellom sidene i et resultat. Sidene er lenker, ikke + * knapper, slik at siden ligger i URL-en og «Tilbake» i nettleseren virker. + * På første og siste side vises teksten uten lenke, så den ikke får fokus. + */ +function Pagination({ page, totalPages, pageLink }: PaginationProps) { + if (totalPages <= 1) { + return null; + } + + const hasPrevious = page > 1; + const hasNext = page < totalPages; + + return ( + + ); +} + +export default Pagination; From 3c2893d00a71ffc0086005ecdebbbe3b6e9ba477 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:52:32 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(pages):=20hent=20katalogen=20fra=20Gra?= =?UTF-8?q?phQL=20med=20=C3=A9n=20side=20om=20gangen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: andretg --- src/App.test.tsx | 125 ++++++++----- src/components/ListingCard/ListingCard.tsx | 4 +- src/components/ListingGrid/ListingGrid.tsx | 4 +- .../QueryError/QueryError.module.css | 19 ++ src/components/QueryError/QueryError.tsx | 27 +++ src/pages/CatalogPage/CatalogPage.module.css | 22 +-- src/pages/CatalogPage/CatalogPage.tsx | 172 ++++++++++++------ src/test/listingMocks.ts | 80 ++++++++ src/test/renderApp.tsx | 23 +++ src/test/setup.ts | 3 + src/utils/catalogPage.test.ts | 30 +++ src/utils/catalogPage.ts | 25 +++ 12 files changed, 413 insertions(+), 121 deletions(-) create mode 100644 src/components/QueryError/QueryError.module.css create mode 100644 src/components/QueryError/QueryError.tsx create mode 100644 src/test/listingMocks.ts create mode 100644 src/test/renderApp.tsx create mode 100644 src/utils/catalogPage.test.ts create mode 100644 src/utils/catalogPage.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 5cc2e05..e0cf8e0 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,23 +1,21 @@ -import { render, screen, within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router'; import { describe, expect, it } from 'vitest'; -import App from './App.tsx'; - -function renderApp(route = '/') { - return render( - - - , - ); -} +import { mockListings } from './data/mockListings.ts'; +import { listingsPageMock, networkErrorMock } from './test/listingMocks.ts'; +import { renderApp } from './test/renderApp.tsx'; function heading() { return screen.getByRole('heading', { level: 1 }); } +/** Venter til katalogen har fått svar fra (den mockede) backenden. */ +function findCards() { + return screen.findAllByRole('article'); +} + describe('navigasjon', () => { - it('viser katalogen på startsiden og markerer aktiv side', () => { + it('viser katalogen på startsiden og markerer aktiv side', async () => { renderApp(); expect(heading()).toHaveTextContent('Finn verktøy'); expect(document.title).toBe('Katalog – Verktøyutlån Trondheim'); @@ -26,6 +24,7 @@ describe('navigasjon', () => { 'aria-current', 'page', ); + await findCards(); }); it('går til utleieskjemaet fra menyen', async () => { @@ -37,9 +36,8 @@ describe('navigasjon', () => { it('åpner detaljvisningen fra et annonsekort og går tilbake', async () => { renderApp(); - await userEvent.click(screen.getByRole('link', { name: 'Drill' })); + await userEvent.click(await screen.findByRole('link', { name: 'Drill' })); expect(heading()).toHaveTextContent('Drill'); - expect(screen.getByText('Moholt')).toBeInTheDocument(); await userEvent.click( screen.getByRole('link', { name: 'Tilbake til katalogen' }), @@ -68,47 +66,88 @@ describe('navigasjon', () => { screen.getByRole('link', { name: 'Gå til katalogen' }), ).toBeInTheDocument(); }); - - it('viser «Fant ikke annonsen» for ukjent annonse-ID', () => { - renderApp('/annonser/finnes-ikke'); - expect(heading()).toHaveTextContent('Fant ikke annonsen'); - }); }); describe('katalogen', () => { - it('leser filtre fra URL-en', () => { - renderApp('/?gratis=1'); - expect( - screen.getByRole('checkbox', { name: 'Kun gratis utlån' }), - ).toBeChecked(); - const cards = screen - .getAllByRole('listitem') - .filter((item) => within(item).queryByRole('heading')); - expect(cards.length).toBeGreaterThan(0); - for (const card of cards) { - expect(card).toHaveTextContent('Gratis utlån'); - } + it('viser lastetilstand og deretter annonsene fra serveren', async () => { + renderApp('/', [listingsPageMock({ totalCount: 10010 })]); + expect(screen.getByRole('status')).toHaveTextContent('Laster annonser'); + + expect(await findCards()).toHaveLength(mockListings.length); + // Antallet kommer fra totalCount, ikke fra antall kort på siden. + expect(screen.getByRole('status')).toHaveTextContent( + /10\s010 annonser · side 1 av 418/, + ); }); - it('oppdaterer antall treff mens brukeren søker', async () => { - renderApp(); - await userEvent.type( - screen.getByRole('searchbox', { name: 'Søk' }), - 'spade', + it('leser siden fra URL-en og lenker til forrige og neste side', async () => { + renderApp('/?side=2', [listingsPageMock({ page: 2, totalCount: 10010 })]); + await findCards(); + expect(screen.getByRole('status')).toHaveTextContent('side 2 av 418'); + + const pages = screen.getByRole('navigation', { name: 'Sider' }); + expect( + within(pages).getByRole('link', { name: 'Forrige' }), + ).toHaveAttribute('href', '/'); + expect(within(pages).getByRole('link', { name: 'Neste' })).toHaveAttribute( + 'href', + '/?side=3', ); - expect(screen.getByRole('status')).toHaveTextContent(/^Viser 2 av \d+/); }); - it('viser tomtilstand og kan fjerne filtrene', async () => { - renderApp('/?q=motorsag'); + it('henter neste side når brukeren blar', async () => { + const firstPage = mockListings.slice(0, 5); + const secondPage = mockListings.slice(5); + renderApp('/', [ + listingsPageMock({ page: 1, items: firstPage, totalCount: 30 }), + listingsPageMock({ page: 2, items: secondPage, totalCount: 30 }), + ]); + await findCards(); + + await userEvent.click(screen.getByRole('link', { name: 'Neste' })); expect( - screen.getByRole('heading', { name: 'Ingen annonser passer søket' }), + await screen.findByRole('link', { name: secondPage[0]!.title }), ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('side 2 av 2'); + }); + + it('viser feil når backenden ikke svarer, og kan prøve igjen', async () => { + renderApp('/', [ + networkErrorMock(listingsPageMock().request), + listingsPageMock(), + ]); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Backenden svarer ikke'); await userEvent.click( - screen.getByRole('button', { name: 'Fjern alle filtre' }), + within(alert).getByRole('button', { name: 'Prøv igjen' }), ); - expect(screen.getByRole('searchbox', { name: 'Søk' })).toHaveValue(''); - expect(screen.getByRole('status')).not.toHaveTextContent(/Viser/); + expect(await findCards()).toHaveLength(mockListings.length); + }); + + it('viser tomtilstand når det ikke finnes annonser', async () => { + renderApp('/', [listingsPageMock({ items: [], totalCount: 0 })]); + expect( + await screen.findByRole('heading', { name: 'Ingen annonser ennå' }), + ).toBeInTheDocument(); + }); + + it('forteller når siden ikke finnes', async () => { + renderApp('/?side=99', [ + listingsPageMock({ page: 99, items: [], totalCount: 10 }), + ]); + expect( + await screen.findByRole('heading', { name: 'Siden finnes ikke' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Gå til første side' }), + ).toHaveAttribute('href', '/'); + }); + + it('viser ikke søk og filtre som bare ville virket på én side', async () => { + renderApp(); + await findCards(); + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('search')).not.toBeInTheDocument(); }); }); diff --git a/src/components/ListingCard/ListingCard.tsx b/src/components/ListingCard/ListingCard.tsx index 6b65829..209f32a 100644 --- a/src/components/ListingCard/ListingCard.tsx +++ b/src/components/ListingCard/ListingCard.tsx @@ -1,12 +1,12 @@ import { Link } from 'react-router'; -import type { Listing } from '../../types/listing.ts'; +import type { ListingSummary } from '../../types/listing.ts'; import { categoryLabels } from '../../utils/listingFormat.ts'; import ListingImage from '../ListingImage/ListingImage.tsx'; import PriceTag from '../PriceTag/PriceTag.tsx'; import styles from './ListingCard.module.css'; interface ListingCardProps { - listing: Listing; + listing: ListingSummary; } /** diff --git a/src/components/ListingGrid/ListingGrid.tsx b/src/components/ListingGrid/ListingGrid.tsx index 96ab233..4f50385 100644 --- a/src/components/ListingGrid/ListingGrid.tsx +++ b/src/components/ListingGrid/ListingGrid.tsx @@ -1,10 +1,10 @@ -import type { Listing } from '../../types/listing.ts'; +import type { ListingSummary } from '../../types/listing.ts'; import ListingCard from '../ListingCard/ListingCard.tsx'; import styles from './ListingGrid.module.css'; interface ListingGridProps { /** Annonsene som skal vises, i den rekkefølgen de skal stå. */ - listings: Listing[]; + listings: ListingSummary[]; } /** diff --git a/src/components/QueryError/QueryError.module.css b/src/components/QueryError/QueryError.module.css new file mode 100644 index 0000000..e384cb0 --- /dev/null +++ b/src/components/QueryError/QueryError.module.css @@ -0,0 +1,19 @@ +.error { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-6); + background: var(--color-surface); + border: 2px solid var(--color-error); + border-radius: var(--radius-md); +} + +.error h2, +.error p { + margin: 0; +} + +.error p { + margin-bottom: var(--space-2); +} diff --git a/src/components/QueryError/QueryError.tsx b/src/components/QueryError/QueryError.tsx new file mode 100644 index 0000000..68f9d98 --- /dev/null +++ b/src/components/QueryError/QueryError.tsx @@ -0,0 +1,27 @@ +import Button from '../Button/Button.tsx'; +import styles from './QueryError.module.css'; + +interface QueryErrorProps { + /** Hva som ikke kunne hentes, for eksempel «annonsene». */ + what: string; + onRetry: () => void; +} + +/** + * Feilmelding når data ikke kan hentes fra backenden, med knapp for å prøve + * igjen. role="alert" gjør at skjermlesere leser meldingen med en gang. + */ +function QueryError({ what, onRetry }: QueryErrorProps) { + return ( +
+

Fikk ikke kontakt med serveren

+

+ Vi kunne ikke hente {what}. Backenden svarer ikke akkurat nå. Prøv igjen + om litt. +

+ +
+ ); +} + +export default QueryError; diff --git a/src/pages/CatalogPage/CatalogPage.module.css b/src/pages/CatalogPage/CatalogPage.module.css index 5ec772b..796e8db 100644 --- a/src/pages/CatalogPage/CatalogPage.module.css +++ b/src/pages/CatalogPage/CatalogPage.module.css @@ -1,22 +1,12 @@ -/* Antall treff til venstre, sortering og «Fjern alle filtre» til høyre. */ -.resultBar { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: var(--space-2) var(--space-4); - margin-block: var(--space-4); -} - -.resultActions { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--space-2) var(--space-4); +.notice { + padding: var(--space-3) var(--space-4); + background: var(--color-surface-muted); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); } .count { - margin: 0; + margin-block: var(--space-4); color: var(--color-text-muted); } diff --git a/src/pages/CatalogPage/CatalogPage.tsx b/src/pages/CatalogPage/CatalogPage.tsx index 00aae6d..f5d5aab 100644 --- a/src/pages/CatalogPage/CatalogPage.tsx +++ b/src/pages/CatalogPage/CatalogPage.tsx @@ -1,43 +1,52 @@ -import Button from '../../components/Button/Button.tsx'; -import CatalogFilters from '../../components/CatalogFilters/CatalogFilters.tsx'; +import { useQuery } from '@apollo/client/react'; +import { useEffect, useRef } from 'react'; +import { Link, useSearchParams } from 'react-router'; import ListingGrid from '../../components/ListingGrid/ListingGrid.tsx'; -import SelectField from '../../components/SelectField/SelectField.tsx'; -import { mockListings } from '../../data/mockListings.ts'; -import { useListingFilters } from '../../hooks/useListingFilters.ts'; -import { useListingSort } from '../../hooks/useListingSort.ts'; -import { usePageTitle } from '../../hooks/usePageTitle.ts'; +import Pagination from '../../components/Pagination/Pagination.tsx'; +import QueryError from '../../components/QueryError/QueryError.tsx'; import { - filterListings, - hasActiveFilters, -} from '../../utils/listingFilters.ts'; + CATALOG_PAGE_SIZE, + LISTINGS_PAGE_QUERY, + type ListingsPageData, +} from '../../graphql/listingQueries.ts'; +import { usePageTitle } from '../../hooks/usePageTitle.ts'; import { - sortLabels, - sortListings, - type ListingSort, -} from '../../utils/listingSort.ts'; + catalogPageLink, + countPages, + readCatalogPage, +} from '../../utils/catalogPage.ts'; import styles from './CatalogPage.module.css'; -const sortOptions = Object.entries(sortLabels).map(([value, label]) => ({ - value, - label, -})); +const countFormatter = new Intl.NumberFormat('nb-NO'); /** - * Katalog med søk, filtre og sortering. Siden henter dataene og sender dem - * videre som props, så bare denne filen må endres når mockdataene byttes ut - * med GraphQL. Søk, filtre og sortering ligger i URL-en. + * Katalog med én side annonser om gangen fra GraphQL-backenden. Gjeldende side + * ligger i URL-en (?side=2), så den overlever en sideoppdatering og kan deles. + * + * Søk, filtre og sortering fra prototypen er tatt ut her til serveren støtter + * dem. Lokalt ville de bare virket på de 24 annonsene som er lastet, ikke på + * hele katalogen. Komponentene og logikken ligger fortsatt i src/. */ function CatalogPage() { usePageTitle('Katalog'); - const [filters, updateFilters] = useListingFilters(); - const [sort, updateSort] = useListingSort(); - // Filtrerer først og sorterer treffene etterpå. - const listings = sortListings(filterListings(mockListings, filters), sort); - const isFiltered = hasActiveFilters(filters); + const [searchParams] = useSearchParams(); + const page = readCatalogPage(searchParams); - function clearFilters() { - updateFilters({ query: '', category: null, freeOnly: false }); - } + const { data, loading, error, refetch } = useQuery(LISTINGS_PAGE_QUERY, { + variables: { + limit: CATALOG_PAGE_SIZE, + offset: (page - 1) * CATALOG_PAGE_SIZE, + }, + }); + + // Ved bytte av side starter brukeren øverst, slik at de nye annonsene vises. + const previousPage = useRef(page); + useEffect(() => { + if (previousPage.current !== page) { + previousPage.current = page; + window.scrollTo({ top: 0 }); + } + }, [page]); return ( <> @@ -45,40 +54,87 @@ function CatalogPage() {

Lei eller lån verktøy av andre studenter og privatpersoner i Trondheim.

+

+ Søk, filtre og sortering kobles til serveren i neste versjon. Nå vises + alle annonsene, med de nyeste først. +

+ + void refetch()} + /> + + ); +} - +interface CatalogResultsProps { + page: number; + data: ListingsPageData | undefined; + loading: boolean; + hasError: boolean; + onRetry: () => void; +} -
- {/* role="status" gjør at skjermlesere leser opp nytt antall treff. */} -

- {isFiltered - ? `Viser ${listings.length} av ${mockListings.length} annonser` - : `${mockListings.length} annonser`} +function CatalogResults({ + page, + data, + loading, + hasError, + onRetry, +}: CatalogResultsProps) { + if (loading) { + return ( +

+ Laster annonser … +

+ ); + } + + if (hasError || !data) { + return ; + } + + const { items, totalCount } = data.listings; + const totalPages = countPages(totalCount, CATALOG_PAGE_SIZE); + + if (totalCount === 0) { + return ( +
+

Ingen annonser ennå

+

Det er ikke lagt ut noen verktøy. Bli den første!

+ Lei ut et verktøy +
+ ); + } + + if (items.length === 0) { + return ( +
+

Siden finnes ikke

+

+ Katalogen har {totalPages} sider, men du prøvde å åpne side {page}.

-
- {isFiltered && listings.length > 0 && ( - - )} - updateSort(event.target.value as ListingSort)} - /> -
+ Gå til første side
+ ); + } - {listings.length > 0 ? ( - - ) : ( -
-

Ingen annonser passer søket

-

Prøv et annet søkeord, eller velg en annen kategori.

- -
- )} + return ( + <> + {/* role="status" gjør at skjermlesere leser opp ny side og nytt antall. */} +

+ {countFormatter.format(totalCount)} annonser · side {page} av{' '} + {totalPages} +

+ + ); } diff --git a/src/test/listingMocks.ts b/src/test/listingMocks.ts new file mode 100644 index 0000000..7d02586 --- /dev/null +++ b/src/test/listingMocks.ts @@ -0,0 +1,80 @@ +import type { MockLink } from '@apollo/client/testing'; +import { mockListings } from '../data/mockListings.ts'; +import { + CATALOG_PAGE_SIZE, + LISTINGS_PAGE_QUERY, + LISTING_QUERY, +} from '../graphql/listingQueries.ts'; +import type { Listing } from '../types/listing.ts'; + +/** + * Mockede GraphQL-svar for testene. Annonsene er de samme eksemplene som i + * prototypen, men komponentene får dem gjennom Apollo, slik de gjør mot + * backenden. Apollo legger til __typename i spørringene, så svarene må ha det. + */ + +function toSummary(listing: Listing) { + const { id, title, category, area, dailyPrice, imageUrl } = listing; + return { + __typename: 'Listing' as const, + id, + title, + category, + area, + dailyPrice, + imageUrl, + }; +} + +interface PageMockOptions { + page?: number; + items?: Listing[]; + totalCount?: number; +} + +/** Svar på katalogspørringen for én side. */ +export function listingsPageMock({ + page = 1, + items = mockListings, + totalCount = items.length, +}: PageMockOptions = {}): MockLink.MockedResponse { + const offset = (page - 1) * CATALOG_PAGE_SIZE; + return { + request: { + query: LISTINGS_PAGE_QUERY, + variables: { limit: CATALOG_PAGE_SIZE, offset }, + }, + result: { + data: { + listings: { + __typename: 'ListingPage', + items: items.map(toSummary), + totalCount, + hasNextPage: offset + items.length < totalCount, + }, + }, + }, + }; +} + +/** Svar på detaljspørringen. null betyr at annonsen ikke finnes. */ +export function listingMock( + id: string, + listing: Listing | null = mockListings.find((item) => item.id === id) ?? null, +): MockLink.MockedResponse { + return { + request: { query: LISTING_QUERY, variables: { id } }, + result: { + data: { + listing: listing && { __typename: 'Listing', ...listing }, + }, + }, + }; +} + +/** Nettverksfeil, som når backenden ikke svarer. */ +export function networkErrorMock( + request: MockLink.MockedResponse['request'], +): MockLink.MockedResponse { + return { request, error: new Error('Failed to fetch') }; +} diff --git a/src/test/renderApp.tsx b/src/test/renderApp.tsx new file mode 100644 index 0000000..ed441cf --- /dev/null +++ b/src/test/renderApp.tsx @@ -0,0 +1,23 @@ +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import App from '../App.tsx'; +import { listingsPageMock } from './listingMocks.ts'; + +/** + * Rendrer hele appen på en gitt adresse med mockede GraphQL-svar. Uten egne + * mocks får katalogen første side med eksempelannonsene. + */ +export function renderApp( + route = '/', + mocks: MockLink.MockedResponse[] = [listingsPageMock()], +) { + return render( + + + + + , + ); +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 36d26bd..9b8baff 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -7,3 +7,6 @@ import { afterEach } from 'vitest'; afterEach(() => { cleanup(); }); + +// jsdom har ikke scrolling. Katalogen scroller til toppen ved bytte av side. +window.scrollTo = () => {}; diff --git a/src/utils/catalogPage.test.ts b/src/utils/catalogPage.test.ts new file mode 100644 index 0000000..8f60b9c --- /dev/null +++ b/src/utils/catalogPage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { catalogPageLink, countPages, readCatalogPage } from './catalogPage.ts'; + +describe('readCatalogPage', () => { + it('leser siden fra URL-en', () => { + expect(readCatalogPage(new URLSearchParams('side=3'))).toBe(3); + }); + + it.each(['', 'side=0', 'side=-2', 'side=abc', 'side=1.5'])( + 'gir første side for «%s»', + (search) => { + expect(readCatalogPage(new URLSearchParams(search))).toBe(1); + }, + ); +}); + +describe('catalogPageLink', () => { + it('lar første side være uten parameter', () => { + expect(catalogPageLink(1)).toBe('/'); + expect(catalogPageLink(2)).toBe('/?side=2'); + }); +}); + +describe('countPages', () => { + it('runder opp, og gir minst én side', () => { + expect(countPages(10010, 24)).toBe(418); + expect(countPages(24, 24)).toBe(1); + expect(countPages(0, 24)).toBe(1); + }); +}); diff --git a/src/utils/catalogPage.ts b/src/utils/catalogPage.ts new file mode 100644 index 0000000..83d9fbd --- /dev/null +++ b/src/utils/catalogPage.ts @@ -0,0 +1,25 @@ +/** Navnet på sideparameteren i URL-en, for eksempel «?side=3». */ +const PARAM_PAGE = 'side'; + +/** + * Leser gjeldende side fra URL-en. Mangler eller ugyldige verdier, som «0», + * «-2» eller «abc», gir første side. + */ +export function readCatalogPage(params: URLSearchParams): number { + const value = params.get(PARAM_PAGE); + if (value === null || !/^\d+$/.test(value)) { + return 1; + } + const page = Number(value); + return page >= 1 ? page : 1; +} + +/** Lenke til en side i katalogen. Første side får ingen parameter. */ +export function catalogPageLink(page: number): string { + return page <= 1 ? '/' : `/?${PARAM_PAGE}=${page}`; +} + +/** Antall sider, minst én, slik at en tom katalog også har «side 1 av 1». */ +export function countPages(totalCount: number, pageSize: number): number { + return Math.max(1, Math.ceil(totalCount / pageSize)); +} From 2327512ef87918e1d25d1bd9b532eca9a254feff Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:53:42 +0200 Subject: [PATCH 6/7] feat(pages): hent detaljvisningen fra GraphQL Co-authored-by: tordtst --- src/App.test.tsx | 51 +++++++++++++++- .../ListingDetailPage/ListingDetailPage.tsx | 60 +++++++++++++++---- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index e0cf8e0..4f315c6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,7 +2,11 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { mockListings } from './data/mockListings.ts'; -import { listingsPageMock, networkErrorMock } from './test/listingMocks.ts'; +import { + listingMock, + listingsPageMock, + networkErrorMock, +} from './test/listingMocks.ts'; import { renderApp } from './test/renderApp.tsx'; function heading() { @@ -35,9 +39,11 @@ describe('navigasjon', () => { }); it('åpner detaljvisningen fra et annonsekort og går tilbake', async () => { - renderApp(); + renderApp('/', [listingsPageMock(), listingMock('listing-001')]); await userEvent.click(await screen.findByRole('link', { name: 'Drill' })); - expect(heading()).toHaveTextContent('Drill'); + expect( + await screen.findByRole('heading', { level: 1, name: 'Drill' }), + ).toBeInTheDocument(); await userEvent.click( screen.getByRole('link', { name: 'Tilbake til katalogen' }), @@ -151,3 +157,42 @@ describe('katalogen', () => { expect(screen.queryByRole('search')).not.toBeInTheDocument(); }); }); + +describe('detaljvisningen', () => { + it('henter annonsen fra serveren og viser alle opplysningene', async () => { + renderApp('/annonser/listing-001', [listingMock('listing-001')]); + expect(screen.getByRole('status')).toHaveTextContent('Laster annonsen'); + + expect( + await screen.findByRole('heading', { level: 1, name: 'Drill' }), + ).toBeInTheDocument(); + expect(screen.getByText('Moholt')).toBeInTheDocument(); + expect(screen.getByText('100 kr/døgn')).toBeInTheDocument(); + expect(screen.getByText('2 batterier')).toBeInTheDocument(); + expect(document.title).toBe('Drill – Verktøyutlån Trondheim'); + }); + + it('viser «Fant ikke annonsen» når serveren svarer null', async () => { + renderApp('/annonser/finnes-ikke', [listingMock('finnes-ikke', null)]); + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Fant ikke annonsen', + }), + ).toBeInTheDocument(); + }); + + it('viser feil når backenden ikke svarer, og kan prøve igjen', async () => { + renderApp('/annonser/listing-001', [ + networkErrorMock(listingMock('listing-001').request), + listingMock('listing-001'), + ]); + const alert = await screen.findByRole('alert'); + await userEvent.click( + within(alert).getByRole('button', { name: 'Prøv igjen' }), + ); + expect( + await screen.findByRole('heading', { level: 1, name: 'Drill' }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/ListingDetailPage/ListingDetailPage.tsx b/src/pages/ListingDetailPage/ListingDetailPage.tsx index e93c23c..9c0aa19 100644 --- a/src/pages/ListingDetailPage/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage/ListingDetailPage.tsx @@ -1,21 +1,51 @@ +import { useQuery } from '@apollo/client/react'; import { Link, useParams } from 'react-router'; import ListingImage from '../../components/ListingImage/ListingImage.tsx'; import PriceTag from '../../components/PriceTag/PriceTag.tsx'; +import QueryError from '../../components/QueryError/QueryError.tsx'; import RentalRequestForm from '../../components/RentalRequestForm/RentalRequestForm.tsx'; -import { mockListings } from '../../data/mockListings.ts'; +import { LISTING_QUERY } from '../../graphql/listingQueries.ts'; import { usePageTitle } from '../../hooks/usePageTitle.ts'; import { categoryLabels, conditionLabels } from '../../utils/listingFormat.ts'; +import type { Listing } from '../../types/listing.ts'; import styles from './ListingDetailPage.module.css'; /** - * Detaljvisning for én annonse, med alle opplysninger og skjema for - * leieforespørsel. Siden henter annonsen selv, så bare denne filen må endres - * når mockdataene byttes ut med GraphQL. + * Detaljvisning for én annonse, hentet fra GraphQL-backenden med listing(id). + * Viser laste- og feiltilstand mens annonsen hentes, og «Fant ikke annonsen» + * når serveren svarer at den ikke finnes. */ function ListingDetailPage() { - const { listingId } = useParams(); - const listing = mockListings.find((item) => item.id === listingId); - usePageTitle(listing ? listing.title : 'Fant ikke annonsen'); + const { listingId = '' } = useParams(); + const { data, loading, error, refetch } = useQuery(LISTING_QUERY, { + variables: { id: listingId }, + }); + const listing = data?.listing; + + usePageTitle( + listing?.title ?? + (loading ? 'Laster annonsen' : error ? 'Feil' : 'Fant ikke annonsen'), + ); + + if (loading) { + return ( + <> + +

Annonse

+

Laster annonsen …

+ + ); + } + + if (error) { + return ( + <> + +

Annonse

+ void refetch()} /> + + ); + } if (!listing) { return ( @@ -29,11 +59,21 @@ function ListingDetailPage() { ); } + return ; +} + +function BackLink() { + return ( +

+ Tilbake til katalogen +

+ ); +} + +function ListingDetails({ listing }: { listing: Listing }) { return ( <> -

- Tilbake til katalogen -

+
From 720bebe30bbb2a41556ae6ffbce3600d18038c99 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:57:11 +0200 Subject: [PATCH 7/7] =?UTF-8?q?docs(docs):=20dokumenter=20Apollo=20Client?= =?UTF-8?q?=20og=20kj=C3=B8ring=20av=20frontend=20mot=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: birkjr --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++--------- docs/design.md | 21 ++++++++++++ docs/ki-bruk.md | 1 + docs/testing.md | 45 ++++++++++++++----------- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c5b53bb..0c27dd5 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ privatpersoner i Trondheim. Bygget med React, TypeScript og Vite. ## Om prototypen -Første innlevering var en klikkbar **frontend-prototype** som viser idéen og -hvordan løsningen er tenkt brukt. Katalogen henter fortsatt data fra mock i -nettleseren, og leie/utleie er demo uten innlogging. +Første innlevering var en klikkbar **frontend-prototype** som viste idéen og +hvordan løsningen er tenkt brukt. -**Datalag (innlevering 2):** PostgreSQL, migrasjon, seed og GraphQL-API ligger i -`backend/`. Frontend henter fortsatt mock i nettleseren; Apollo Client kommer i -senere issue. Se [Backend og database](#backend-og-database). +**Innlevering 2:** PostgreSQL, migrasjon, seed og GraphQL-API ligger i +`backend/`. Katalogen og detaljvisningen henter nå annonsene fra backenden med +Apollo Client, én side om gangen. Leieforespørsel og utleieskjema er fortsatt +demo. Se [Frontend mot GraphQL](#frontend-mot-graphql) og +[Backend og database](#backend-og-database). ### Idé @@ -27,7 +28,7 @@ låne verktøy i nærheten i stedet for å kjøpe nytt. | Visning | Slik kommer du dit | Hva du kan gjøre | | -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Se alle annonsene, søke, filtrere på kategori og gratis utlån og sortere på pris | +| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Bla gjennom alle annonsene i databasen, 24 om gangen, med de nyeste først | | Detaljvisning | Klikk på et annonsekort i katalogen | Se bilde, beskrivelse, tilstand, tilbehør og pris, velge datoer og se samlet pris | | Lei ut verktøy | «Lei ut verktøy» i menyen | Fylle ut skjemaet for en ny annonse med tittel, kategori, beskrivelse, område og pris | @@ -49,9 +50,8 @@ annonser viser for eksempel «150 kr/døgn», mens gratis annonser viser Ingen forespørsel blir sendt, og verktøyet blir ikke reservert. - **Utleieskjemaet er en demo.** Knappen heter «Legg ut verktøy (demo)». Annonsen blir ikke lagret og vises ikke i katalogen. -- **Appen lagrer ikke leie eller nye annonser.** Utleie- og leieforespørsel er - demo. Søk og filtre i katalogen går mot mockdata i nettleseren. Seed i - databasen er for backend og senere API, ikke noe katalogen leser ennå. +- **Appen lagrer ikke leie eller nye annonser ennå.** Utleieskjema og + leieforespørsel er demo. Katalogen og detaljvisningen leser fra databasen. ### Data @@ -131,9 +131,61 @@ TypeScript 7. ```bash npm ci # installer avhengigheter nøyaktig etter package-lock.json -npm run dev # start utviklingsserver på http://localhost:5173 +npm run dev # start utviklingsserver på http://localhost:5173/project2/ ``` +Katalogen henter annonsene fra backenden. Uten backend viser appen en +feilmelding med «Prøv igjen». Se [Frontend og backend sammen](#frontend-og-backend-sammen). + +## Frontend mot GraphQL + +Frontend bruker [Apollo Client](https://www.apollographql.com/docs/react/) for +å hente data fra GraphQL-backenden. Vi valgte Apollo fordi: + +- **Cache:** Svar lagres i en normalisert cache. En side eller annonse som er + hentet før, vises med en gang og uten ny forespørsel når brukeren går + tilbake. +- **Tilstand:** `useQuery` gir laste-, feil- og datatilstand og `refetch` for + «Prøv igjen», så vi slipper å skrive dette selv. +- **Testing:** `MockedProvider` lar komponenttestene bruke mockede + GraphQL-svar uten backend. +- **Videre arbeid:** Apollo er laget for GraphQL og håndterer mutasjoner og + oppdatering av cachen når brukere lagrer data. + +| Fil | Innhold | +| ------------------------------- | -------------------------------------------- | +| `src/graphql/client.ts` | Apollo-klienten og adressen til API-et | +| `src/graphql/listingQueries.ts` | Spørringene for katalogen og detaljvisningen | +| `src/test/listingMocks.ts` | Mockede GraphQL-svar til testene | + +Katalogen henter bare feltene annonsekortet viser, og 24 annonser per side. +Én side er omtrent 4 kB. Resten av opplysningene hentes når brukeren åpner en +annonse. Gjeldende side ligger i URL-en som `?side=2`. + +**Samme adresser lokalt og på VM-en:** Appen har `base: '/project2/'` i +`vite.config.ts`, og klienten sender spørringer til `/project2/api/graphql`. +På VM-en sender Apache dette videre til backenden. Lokalt gjør Vite det samme +med en proxy til `http://localhost:3001`. Annonsebildene ligger i +`public/assets/listings/` og har samme adresse som `image_url` i databasen. + +### Frontend og backend sammen + +Første gang: sett opp PostgreSQL, `backend/.env`, migrasjon og seed som +beskrevet i [Lokal backend-utvikling](#lokal-backend-utvikling). Deretter, i +to terminaler: + +```bash +# Terminal 1: backend på port 3001 +cd backend +npm run dev + +# Terminal 2: frontend på port 5173 +npm run dev +``` + +Åpne . Kjør `npm run seed:large` i `backend/` +for å se katalogen med 10 010 annonser. + ## Backend og database Backend ligger i `backend/` og bruker PostgreSQL. Apollo Server 5 eksponerer @@ -347,15 +399,17 @@ backend/ src/server.ts Apollo Server (GraphQL) src/ types/ Felles datatyper, for eksempel Listing - data/ Mockdata. Byttes med GraphQL-backend senere + graphql/ Apollo-klient og GraphQL-spørringer + data/ Eksempelannonser til designreferansen og testene utils/ Formatering av pris og etiketter, filtrering og sortering - assets/ Illustrasjoner til eksempelannonsene styles/ Designvariabler og grunnstiler components/ Gjenbrukbare komponenter, én mappe per komponent pages/ Én mappe per side hooks/ Små felles hooks, for eksempel usePageTitle design/ Designreferanse, bare under utvikling - test/ Felles oppsett for testene + test/ Felles oppsett, mockede GraphQL-svar og renderApp for testene +public/ + assets/listings/ Illustrasjoner til annonsene, samme sti som image_url ``` Komponenter i `components/` får data gjennom props og importerer ikke @@ -368,7 +422,7 @@ Rutene er definert i `src/App.tsx` med React Router. | Rute | Side | Innhold nå | | ---------------------- | ------------------- | ------------------------------------------- | -| `/` | `CatalogPage` | Annonsekort med søk, filtre og sortering | +| `/` | `CatalogPage` | Annonser fra GraphQL, én side om gangen | | `/annonser/:listingId` | `ListingDetailPage` | Alle opplysninger og leieforespørsel (demo) | | `/lei-ut` | `RentOutPage` | Skjema for å legge ut verktøy (demo) | | Alle andre | `NotFoundPage` | «Fant ikke siden» | @@ -379,6 +433,11 @@ hopp-lenke automatisk. ## Søk, filtre og sortering +> **Midlertidig skjult.** Katalogen henter nå én side om gangen fra serveren. +> Søk, filtre og sortering i nettleseren ville bare virket på de 24 annonsene +> som er lastet, ikke på hele katalogen. De er derfor tatt ut av katalogen til +> serveren støtter dem. Logikken og testene under ligger fortsatt i `src/`. + Katalogen kan snevres inn med fritekstsøk, kategori og gratis utlån. Søket leter i tittel og beskrivelse og skiller ikke mellom store og små bokstaver. Filtrene kan kombineres, og et tomt søk viser alle annonser. diff --git a/docs/design.md b/docs/design.md index 5667c62..5adcb9b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -147,6 +147,27 @@ bekreftelsen sier at ingenting er sendt eller lagret. Bekreftelsen ligger i et `role="status"`-område, så skjermlesere leser den opp. Bruk `key`, slik at skjemaet nullstilles når brukeren går til en annen annonse. +### Pagination + +«Forrige» og «Neste» mellom sidene, med «Side 2 av 418» mellom. Sidene er +lenker, så siden ligger i URL-en og «Tilbake» i nettleseren virker. På første +og siste side vises teksten uten lenke og med `aria-disabled`, så den ikke får +fokus. Komponenten vises ikke når alt får plass på én side. + +```tsx + +``` + +### QueryError + +Feilmelding når data ikke kan hentes fra backenden, med knappen «Prøv igjen». +Den har `role="alert"`, så skjermlesere leser den med en gang, og rød ramme i +tillegg til teksten. + +```tsx + void refetch()} /> +``` + ### ListingGrid Responsivt rutenett med ett `ListingCard` per annonse. Annonsene vises i den diff --git a/docs/ki-bruk.md b/docs/ki-bruk.md index 06283d9..b6e69f7 100644 --- a/docs/ki-bruk.md +++ b/docs/ki-bruk.md @@ -44,3 +44,4 @@ ser hva vi bør være oppmerksomme på. | #35 | Undersøkte eksisterende backend, utleieskjema og `listings`-tabell; foreslo og kontrollerte Apollo Server 5, GraphQL-schema, resolvere, PostgreSQL-repository med parameterisert SQL, servervalidering som frontend, og Vitest-integrasjonstester mot egen testdatabase | Typecheck og `npm test` i `backend/` mot lokal database med `_test`-suffiks. README, testing.md og deploy-maler oppdatert. Frontend uendret. PR og merge gjøres av gruppen | | #39 | Søkte etter åpne datasett om verktøy på nettet (Wikidata, Open Products Facts, Geonorge) og foreslo Wikidata for verktøytyper og merker. Veiledet oppsett av lokal PostgreSQL-database. Skrev skript som henter verktøymodeller fra Wikidata (`import:tools`), migrasjon for `created_at`, generator for varierte annonser med tester og `seed:large` som setter inn datasettet | Wikidata-kategoriene ga mye støy, så verktøytypene velges med faste ID-er. Stikkprøver av genererte annonser viste tekster, tilbehør og merker som ikke passet verktøyet (for eksempel «båndsliper» som «passer til å skru opp hyller»), og dette ble rettet før commit. Migrasjon testet med up/down mot lokal database. | | #42 | Laget paginering i `listings` med validering, `createdAt` i skjemaet, indeks for sorteringen og API-tester, og målte spørringene med `EXPLAIN ANALYZE` | Kjørt mot ekte PostgreSQL 18 lokalt med 10 010 og 50 010 annonser. En test for rekkefølge ved like tidspunkt bestod også med feil sortering, fordi seed-annonsene har ulike tidspunkt. Testen ble skrevet om med egne testannonser og kontrollert ved å innføre feilen med vilje. Målingene viste at sider langt bak blir tregere med `OFFSET`, og det er dokumentert | +| #43 | Hjalp med oppsett av Apollo Client, katalog med sider, detaljvisning mot GraphQL og mockede GraphQL-svar i testene | Gruppen testet hele flyten lokalt mot PostgreSQL med 10 010 annonser, også feiltilstanden med backenden stoppet, og koden gjennomgås i PR før merge | diff --git a/docs/testing.md b/docs/testing.md index 5d17961..d4c80f5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -59,25 +59,32 @@ testdatabase-URL kjører fortsatt. ## Hva som testes -| Fil | Hva som testes | -| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/App.test.tsx` | Navigasjon via meny, annonsekort og tilbakelenke, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke, 404-sider, katalogsøk | -| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | -| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | -| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | -| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | -| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | -| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | -| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | -| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | -| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | -| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | -| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | -| `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | -| `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | -| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | - -Status 1. oktober 2026: 11 frontend-testfiler og 79 tester, alle grønne. +| Fil | Hva som testes | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.test.tsx` | Navigasjon, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke og 404. Katalog og detaljvisning mot mockede GraphQL-svar: lasting, antall fra serveren, sider i URL-en, blaing, feil med «Prøv igjen», tom katalog, side som ikke finnes og ukjent annonse | +| `components/Pagination/` | Lenker til forrige og neste side, ingen lenke på første og siste side, skjult når alt får plass på én side | +| `utils/catalogPage.test.ts` | Lesing av `?side=` fra URL-en, lenker til sider og antall sider | +| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | +| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | +| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | +| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | +| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | +| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | +| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | +| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | +| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | +| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | +| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | +| `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | +| `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | +| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | + +Status 14. oktober 2026, frontend: 13 testfiler og 97 tester, alle grønne. + +Komponenttestene bruker `MockedProvider` fra Apollo med svarene i +`src/test/listingMocks.ts`, og `renderApp` i `src/test/renderApp.tsx` rendrer +hele appen med mockene. Hver mock brukes én gang, så en test som henter samme +side to ganger, for eksempel «Prøv igjen», trenger to mocks. Status 14. oktober 2026, backend: 5 testfiler og 41 tester, alle grønne mot en lokal testdatabase med PostgreSQL 18.