From 99599b7d7606abe4167a08cf1f27ea6b7bdbc7d7 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:01:20 +0100 Subject: [PATCH 01/19] feat(backend): legg til tabellen reviews for vurderinger av annonser --- .../1793307661044_create-reviews-table.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/migrations/1793307661044_create-reviews-table.ts diff --git a/backend/migrations/1793307661044_create-reviews-table.ts b/backend/migrations/1793307661044_create-reviews-table.ts new file mode 100644 index 0000000..f44082f --- /dev/null +++ b/backend/migrations/1793307661044_create-reviews-table.ts @@ -0,0 +1,58 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** Samme grense som REVIEW_COMMENT_MAX_LENGTH i backend og frontend. */ +const COMMENT_MAX_LENGTH = 500; + +/** + * Vurderinger av annonser: en score fra 1 til 5 og en valgfri kommentar. + * Grensene sjekkes også i databasen, slik at ugyldige vurderinger ikke kan + * lagres selv om valideringen i backenden skulle bli omgått. + * + * Slettes en annonse, slettes vurderingene av den også (ON DELETE CASCADE). + * + * Indeksen har samme rekkefølge som listen over de nyeste vurderingene, så den + * kan leses rett fra indeksen. Den brukes også når snittscore og antall regnes + * ut for én annonse, fordi listing_id står først. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable('reviews', { + id: { type: 'text', primaryKey: true }, + listing_id: { + type: 'text', + notNull: true, + references: 'listings', + onDelete: 'CASCADE', + }, + score: { type: 'integer', notNull: true }, + comment: { type: 'text', notNull: true, default: '' }, + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); + + pgm.addConstraint('reviews', 'reviews_score_check', { + check: 'score BETWEEN 1 AND 5', + }); + + pgm.addConstraint('reviews', 'reviews_comment_length_check', { + check: `char_length(comment) <= ${COMMENT_MAX_LENGTH}`, + }); + + pgm.createIndex( + 'reviews', + [ + 'listing_id', + { name: 'created_at', sort: 'DESC' }, + { name: 'id', sort: 'DESC' }, + ], + { name: 'reviews_listing_id_created_at_id_idx' }, + ); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable('reviews'); +} From 3716fd0293beb526c93027b7267056e8996fdde1 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:01:47 +0100 Subject: [PATCH 02/19] feat(backend): valider nye vurderinger --- .../src/validation/createReviewInput.test.ts | 79 +++++++++++++++++++ backend/src/validation/createReviewInput.ts | 70 ++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 backend/src/validation/createReviewInput.test.ts create mode 100644 backend/src/validation/createReviewInput.ts diff --git a/backend/src/validation/createReviewInput.test.ts b/backend/src/validation/createReviewInput.test.ts new file mode 100644 index 0000000..9e21b6e --- /dev/null +++ b/backend/src/validation/createReviewInput.test.ts @@ -0,0 +1,79 @@ +import { GraphQLError } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_REVIEWS_LIMIT, + MAX_REVIEWS_LIMIT, + REVIEW_COMMENT_MAX_LENGTH, + validateCreateReviewInput, + validateReviewsLimit, +} from './createReviewInput.js'; + +function expectBadUserInput(run: () => unknown) { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(GraphQLError); + expect((error as GraphQLError).extensions.code).toBe('BAD_USER_INPUT'); + return; + } + throw new Error('Forventet BAD_USER_INPUT, men ingen feil ble kastet.'); +} + +const valid = { listingId: 'listing-001', score: 4, comment: 'Fin drill.' }; + +describe('validateCreateReviewInput', () => { + it('godtar en gyldig vurdering', () => { + expect(validateCreateReviewInput(valid)).toEqual(valid); + }); + + it.each([1, 5])('godtar grenseverdien %i', (score) => { + expect(validateCreateReviewInput({ ...valid, score }).score).toBe(score); + }); + + it.each([0, 6, -1, 4.5])('avviser score %s', (score) => { + expectBadUserInput(() => validateCreateReviewInput({ ...valid, score })); + }); + + it('fjerner mellomrom i endene av kommentaren', () => { + expect( + validateCreateReviewInput({ ...valid, comment: ' Fin drill. ' }) + .comment, + ).toBe('Fin drill.'); + }); + + it.each([undefined, null, '', ' '])( + 'gjør kommentaren %o om til tom tekst', + (comment) => { + expect(validateCreateReviewInput({ ...valid, comment }).comment).toBe(''); + }, + ); + + it('godtar en kommentar på nøyaktig maksimal lengde', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH); + expect(validateCreateReviewInput({ ...valid, comment }).comment).toBe( + comment, + ); + }); + + it('avviser en for lang kommentar', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 1); + expectBadUserInput(() => validateCreateReviewInput({ ...valid, comment })); + }); +}); + +describe('validateReviewsLimit', () => { + it('bruker standardverdien når ingenting er oppgitt', () => { + expect(validateReviewsLimit(undefined)).toBe(DEFAULT_REVIEWS_LIMIT); + expect(validateReviewsLimit(null)).toBe(DEFAULT_REVIEWS_LIMIT); + }); + + it('godtar grenseverdiene', () => { + expect(validateReviewsLimit(1)).toBe(1); + expect(validateReviewsLimit(MAX_REVIEWS_LIMIT)).toBe(MAX_REVIEWS_LIMIT); + }); + + it.each([0, -1, MAX_REVIEWS_LIMIT + 1])('avviser %i', (limit) => { + expectBadUserInput(() => validateReviewsLimit(limit)); + }); +}); diff --git a/backend/src/validation/createReviewInput.ts b/backend/src/validation/createReviewInput.ts new file mode 100644 index 0000000..2e8d5a0 --- /dev/null +++ b/backend/src/validation/createReviewInput.ts @@ -0,0 +1,70 @@ +import { GraphQLError } from 'graphql'; + +/** Laveste og høyeste score. Samme grenser som i databasen. */ +export const REVIEW_SCORE_MIN = 1; +export const REVIEW_SCORE_MAX = 5; + +/** Lengste kommentar, etter at mellomrom i endene er fjernet. */ +export const REVIEW_COMMENT_MAX_LENGTH = 500; + +/** Antall vurderinger Listing.reviews gir når klienten ikke ber om noe annet. */ +export const DEFAULT_REVIEWS_LIMIT = 10; +/** Flest vurderinger én spørring kan hente. */ +export const MAX_REVIEWS_LIMIT = 50; + +export interface CreateReviewInput { + listingId: string; + score: number; + comment?: string | null; +} + +export interface ValidatedCreateReviewInput { + listingId: string; + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; +} + +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +/** + * Validerer createReview-input. GraphQL sikrer at score er et heltall, så her + * sjekkes grensene. Kommentaren er valgfri, og null blir tom tekst. Om + * annonsen finnes, sjekkes når vurderingen lagres. + */ +export function validateCreateReviewInput( + input: CreateReviewInput, +): ValidatedCreateReviewInput { + const { score } = input; + if ( + !Number.isInteger(score) || + score < REVIEW_SCORE_MIN || + score > REVIEW_SCORE_MAX + ) { + invalidInput( + `Velg en vurdering fra ${REVIEW_SCORE_MIN} til ${REVIEW_SCORE_MAX}.`, + ); + } + + const comment = input.comment?.trim() ?? ''; + if (comment.length > REVIEW_COMMENT_MAX_LENGTH) { + invalidInput(`Kommentaren kan ha høyst ${REVIEW_COMMENT_MAX_LENGTH} tegn.`); + } + + return { listingId: input.listingId, score, comment }; +} + +/** Validerer hvor mange vurderinger Listing.reviews skal gi. */ +export function validateReviewsLimit(limit: number | null | undefined): number { + const value = limit ?? DEFAULT_REVIEWS_LIMIT; + if (!Number.isInteger(value) || value < 1 || value > MAX_REVIEWS_LIMIT) { + invalidInput( + `Antall vurderinger (limit) må være et heltall fra 1 til ${MAX_REVIEWS_LIMIT}.`, + ); + } + return value; +} From bd8ec13d16454da87f5070d9ff909f5db0cc050b Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:03:10 +0100 Subject: [PATCH 03/19] feat(backend): legg til vurderinger, snittscore og createReview i GraphQL-API-et --- backend/src/db/listingsRepository.ts | 49 +++++++++++++++---- backend/src/db/reviewsRepository.ts | 70 ++++++++++++++++++++++++++++ backend/src/graphql/resolvers.ts | 34 ++++++++++++++ backend/src/graphql/schema.ts | 39 ++++++++++++++++ backend/src/types/listing.ts | 12 ++++- backend/src/types/review.ts | 31 ++++++++++++ 6 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 backend/src/db/reviewsRepository.ts create mode 100644 backend/src/types/review.ts diff --git a/backend/src/db/listingsRepository.ts b/backend/src/db/listingsRepository.ts index 83c2bf4..6b1d8bb 100644 --- a/backend/src/db/listingsRepository.ts +++ b/backend/src/db/listingsRepository.ts @@ -27,6 +27,26 @@ const LISTING_COLUMNS = ` created_at `; +/** + * Antall vurderinger og snittscore for annonsen l. Brukes med CROSS JOIN + * LATERAL, så det regnes ut bare for annonsene som returneres, og hver + * utregning slår opp i indeksen på reviews.listing_id. En side med 24 + * annonser gir da én spørring, ikke én ekstra spørring per annonse. + * + * En aggregering uten GROUP BY gir alltid én rad, så annonser uten + * vurderinger får 0 og null. AVG gir numeric, som pg leverer som tekst, så + * den gjøres om til float8 for å bli et tall i JavaScript. + */ +const REVIEW_STATS_JOIN = ` + CROSS JOIN LATERAL ( + SELECT + COUNT(*)::int AS review_count, + AVG(score)::float8 AS average_score + FROM reviews + WHERE reviews.listing_id = l.id + ) AS review_stats +`; + export interface ListingPage { items: Listing[]; totalCount: number; @@ -55,13 +75,20 @@ export async function findListingsPage({ const where = buildListingsWhere(filter); const limitParam = where.values.length + 1; + // Siden velges først i den indre spørringen. Vurderingene hentes etterpå, + // bare for annonsene på siden, og rekkefølgen settes på nytt etter joinen. const [itemsResult, countResult] = await Promise.all([ pool.query( - `SELECT ${LISTING_COLUMNS} - FROM listings - ${where.sql} - ORDER BY ${LISTING_ORDER_BY[sort]} - LIMIT $${limitParam} OFFSET $${limitParam + 1}`, + `SELECT l.*, review_stats.* + FROM ( + SELECT ${LISTING_COLUMNS} + FROM listings + ${where.sql} + ORDER BY ${LISTING_ORDER_BY[sort]} + LIMIT $${limitParam} OFFSET $${limitParam + 1} + ) AS l + ${REVIEW_STATS_JOIN} + ORDER BY ${LISTING_ORDER_BY[sort]}`, [...where.values, limit, offset], ), pool.query<{ total: string }>( @@ -93,7 +120,10 @@ export async function findListingAreas(): Promise { export async function findListingById(id: string): Promise { const result = await pool.query( - `SELECT ${LISTING_COLUMNS} FROM listings WHERE id = $1`, + `SELECT l.*, review_stats.* + FROM listings AS l + ${REVIEW_STATS_JOIN} + WHERE l.id = $1`, [id], ); const row = result.rows[0]; @@ -108,7 +138,9 @@ export async function insertListing( ): Promise { const id = `listing-${randomUUID()}`; - const result = await pool.query( + const result = await pool.query< + Omit + >( `INSERT INTO listings ( id, title, @@ -140,7 +172,8 @@ export async function insertListing( throw new Error('INSERT RETURNING ga ingen rad.'); } - return listingFromRow(row); + // En ny annonse har ingen vurderinger ennå. + return listingFromRow({ ...row, review_count: 0, average_score: null }); } export async function deleteListingById(id: string): Promise { diff --git a/backend/src/db/reviewsRepository.ts b/backend/src/db/reviewsRepository.ts new file mode 100644 index 0000000..60c43f6 --- /dev/null +++ b/backend/src/db/reviewsRepository.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; + +import type { Review, ReviewRow } from '../types/review.js'; +import { reviewFromRow } from '../types/review.js'; +import type { ValidatedCreateReviewInput } from '../validation/createReviewInput.js'; +import { pool } from './pool.js'; + +const REVIEW_COLUMNS = 'id, listing_id, score, comment, created_at'; + +/** PostgreSQL-koden for brudd på en fremmednøkkel. */ +const FOREIGN_KEY_VIOLATION = '23503'; + +function isForeignKeyViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === FOREIGN_KEY_VIOLATION + ); +} + +/** + * Lagrer en vurdering. Returnerer null når annonsen ikke finnes. Det sjekkes + * av fremmednøkkelen i databasen, så en annonse som slettes samtidig, ikke + * kan få en vurdering som peker på ingenting. + */ +export async function insertReview( + input: ValidatedCreateReviewInput, +): Promise { + const id = `review-${randomUUID()}`; + + try { + const result = await pool.query( + `INSERT INTO reviews (id, listing_id, score, comment) + VALUES ($1, $2, $3, $4) + RETURNING ${REVIEW_COLUMNS}`, + [id, input.listingId, input.score, input.comment], + ); + const row = result.rows[0]; + if (!row) { + throw new Error('INSERT RETURNING ga ingen rad.'); + } + return reviewFromRow(row); + } catch (error) { + if (isForeignKeyViolation(error)) { + return null; + } + throw error; + } +} + +/** + * De nyeste vurderingene av én annonse, nyeste først. id er siste + * sorteringsnøkkel, slik at vurderinger med samme tidspunkt alltid kommer i + * samme rekkefølge. Rekkefølgen er den samme som i indeksen + * reviews_listing_id_created_at_id_idx. + */ +export async function findReviewsForListing( + listingId: string, + limit: number, +): Promise { + const result = await pool.query( + `SELECT ${REVIEW_COLUMNS} + FROM reviews + WHERE listing_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [listingId, limit], + ); + return result.rows.map(reviewFromRow); +} diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 7f31b65..884c3b5 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,11 +1,23 @@ +import { GraphQLError } from 'graphql'; + import { findListingAreas, findListingById, findListingsPage, insertListing, } from '../db/listingsRepository.js'; +import { + findReviewsForListing, + insertReview, +} from '../db/reviewsRepository.js'; +import type { Listing } from '../types/listing.js'; import type { CreateListingInput } from '../validation/createListingInput.js'; import { validateCreateListingInput } from '../validation/createListingInput.js'; +import type { CreateReviewInput } from '../validation/createReviewInput.js'; +import { + validateCreateReviewInput, + validateReviewsLimit, +} from '../validation/createReviewInput.js'; import type { ListingFilterArgs } from '../validation/listingFilterArgs.js'; import { validateListingFilterArgs } from '../validation/listingFilterArgs.js'; import type { PaginationArgs } from '../validation/paginationArgs.js'; @@ -28,5 +40,27 @@ export const resolvers = { const validated = validateCreateListingInput(args.input); return insertListing(validated); }, + createReview: async ( + _parent: unknown, + args: { input: CreateReviewInput }, + ) => { + const validated = validateCreateReviewInput(args.input); + const review = await insertReview(validated); + // Annonsen hentes etter at vurderingen er lagret, så snitt og antall + // tar med den nye vurderingen. + const listing = review && (await findListingById(validated.listingId)); + if (!review || !listing) { + throw new GraphQLError('Fant ikke annonsen.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return { review, listing }; + }, + }, + Listing: { + // Hentes bare når klienten ber om feltet, som i detaljvisningen. Katalogen + // spør ikke etter det, så den gir ingen ekstra spørringer. + reviews: (parent: Listing, args: { limit?: number | null }) => + findReviewsForListing(parent.id, validateReviewsLimit(args.limit)), }, }; diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 9e69bde..61d22b9 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -26,6 +26,23 @@ export const typeDefs = `#graphql imageUrl: String "Når annonsen ble opprettet, som ISO 8601-tekst i UTC." createdAt: String! + "Snittet av alle vurderingene, fra 1 til 5. null når annonsen ikke har vurderinger." + averageScore: Float + "Antall vurderinger av annonsen." + reviewCount: Int! + "De nyeste vurderingene, nyeste først. limit er fra 1 til 50." + reviews(limit: Int = 10): [Review!]! + } + + "En vurdering av en annonse. Hvem som helst kan gi en vurdering." + type Review { + id: ID! + "Fra 1 til 5, der 5 er best." + score: Int! + "Tom tekst når vurderingen ikke har kommentar." + comment: String! + "Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC." + createdAt: String! } "Rekkefølgen annonsene sorteres i." @@ -95,7 +112,29 @@ export const typeDefs = `#graphql listingAreas: [String!]! } + input CreateReviewInput { + listingId: ID! + "Fra 1 til 5, der 5 er best." + score: Int! + "Valgfri, høyst 500 tegn. Mellomrom i endene fjernes." + comment: String + } + + """ + Svaret på createReview. listing har oppdatert snitt, antall og liste over + vurderinger, slik at klienten kan oppdatere visningen uten ny spørring. + """ + type CreateReviewPayload { + review: Review! + listing: Listing! + } + type Mutation { createListing(input: CreateListingInput!): Listing! + """ + Lagrer en vurdering av en annonse. Gir BAD_USER_INPUT når score ikke er + fra 1 til 5, kommentaren er for lang eller annonsen ikke finnes. + """ + createReview(input: CreateReviewInput!): CreateReviewPayload! } `; diff --git a/backend/src/types/listing.ts b/backend/src/types/listing.ts index 8cc2ad2..8658245 100644 --- a/backend/src/types/listing.ts +++ b/backend/src/types/listing.ts @@ -17,9 +17,15 @@ export interface Listing { imageUrl: string | null; /** Når annonsen ble opprettet, som ISO 8601-tekst i UTC. */ createdAt: string; + /** Snittet av vurderingene, fra 1 til 5, eller null uten vurderinger. */ + averageScore: number | null; + reviewCount: number; } -/** Rad fra PostgreSQL-tabellen listings. */ +/** + * Rad fra PostgreSQL-tabellen listings, med antall vurderinger og snittscore + * regnet ut fra tabellen reviews. + */ export interface ListingRow { id: string; title: string; @@ -32,6 +38,8 @@ export interface ListingRow { image_url: string | null; /** pg gjør timestamptz om til Date. */ created_at: Date; + review_count: number; + average_score: number | null; } export function listingFromRow(row: ListingRow): Listing { @@ -46,5 +54,7 @@ export function listingFromRow(row: ListingRow): Listing { accessories: row.accessories, imageUrl: row.image_url, createdAt: row.created_at.toISOString(), + averageScore: row.average_score, + reviewCount: row.review_count, }; } diff --git a/backend/src/types/review.ts b/backend/src/types/review.ts new file mode 100644 index 0000000..fd50994 --- /dev/null +++ b/backend/src/types/review.ts @@ -0,0 +1,31 @@ +/** Speiler typen Review i GraphQL-skjemaet. */ +export interface Review { + id: string; + listingId: string; + /** Fra 1 til 5, der 5 er best. */ + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; + /** Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC. */ + createdAt: string; +} + +/** Rad fra PostgreSQL-tabellen reviews. */ +export interface ReviewRow { + id: string; + listing_id: string; + score: number; + comment: string; + /** pg gjør timestamptz om til Date. */ + created_at: Date; +} + +export function reviewFromRow(row: ReviewRow): Review { + return { + id: row.id, + listingId: row.listing_id, + score: row.score, + comment: row.comment, + createdAt: row.created_at.toISOString(), + }; +} From 457d0e9a5bfe53e95727ad8664f9cec3ecb7e0fb Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:04:12 +0100 Subject: [PATCH 04/19] test(backend): test vurderinger, snittscore og createReview --- backend/src/graphql/reviews.api.test.ts | 306 ++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 backend/src/graphql/reviews.api.test.ts diff --git a/backend/src/graphql/reviews.api.test.ts b/backend/src/graphql/reviews.api.test.ts new file mode 100644 index 0000000..5e4b2bb --- /dev/null +++ b/backend/src/graphql/reviews.api.test.ts @@ -0,0 +1,306 @@ +import type { ApolloServer } from '@apollo/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { + closeTestPool, + createTestApolloServer, +} from '../test/apolloTestServer.js'; +import { hasTestDatabase } from '../test/env.js'; +import { + MAX_REVIEWS_LIMIT, + REVIEW_COMMENT_MAX_LENGTH, +} from '../validation/createReviewInput.js'; + +/** Annonsene testene vurderer. Ordet MARKER gjør dem lette å søke fram. */ +const MARKER = 'vurderingstest'; +const LISTING_ID = 'test-review-listing'; +const OTHER_LISTING_ID = 'test-review-other'; +const FIXTURE_IDS = [LISTING_ID, OTHER_LISTING_ID]; +const UNKNOWN_LISTING_ID = 'listing-finnes-ikke'; + +const CREATE_REVIEW = `mutation ($input: CreateReviewInput!) { + createReview(input: $input) { + review { id score comment createdAt } + listing { + id + averageScore + reviewCount + reviews(limit: 10) { id score comment } + } + } +}`; + +const LISTING_WITH_REVIEWS = `query ($id: ID!, $limit: Int) { + listing(id: $id) { + averageScore + reviewCount + reviews(limit: $limit) { id score comment createdAt } + } +}`; + +interface ReviewResult { + id: string; + score: number; + comment: string; + createdAt: string; +} + +interface ListingResult { + averageScore: number | null; + reviewCount: number; + reviews: ReviewResult[]; +} + +describe.skipIf(!hasTestDatabase)('vurderinger', () => { + let server: ApolloServer; + + async function run(query: string, variables: Record) { + const response = await server.executeOperation({ query, variables }); + if (response.body.kind !== 'single') { + throw new Error('Forventet ett svar fra GraphQL.'); + } + return response.body.singleResult; + } + + async function createReview(input: Record) { + return run(CREATE_REVIEW, { + input: { listingId: LISTING_ID, ...input }, + }); + } + + async function fetchListing( + id = LISTING_ID, + limit?: number, + ): Promise { + const result = await run(LISTING_WITH_REVIEWS, { id, limit }); + expect(result.errors).toBeUndefined(); + return result.data?.listing as ListingResult; + } + + async function pool() { + return (await import('../db/pool.js')).pool; + } + + beforeAll(async () => { + server = await createTestApolloServer(); + const db = await pool(); + for (const id of FIXTURE_IDS) { + await db.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition) + VALUES ($1, 'Testannonse', 'handTools', $2, 'Moholt', 50, 'good') + ON CONFLICT (id) DO NOTHING`, + [id, `Test av vurderinger. ${MARKER}`], + ); + } + }); + + // Hver test starter uten vurderinger, så antall og snitt er kjent. + beforeEach(async () => { + const db = await pool(); + await db.query('DELETE FROM reviews WHERE listing_id = ANY($1)', [ + FIXTURE_IDS, + ]); + }); + + afterAll(async () => { + const db = await pool(); + // Vurderingene slettes sammen med annonsene (ON DELETE CASCADE). + await db.query('DELETE FROM listings WHERE id = ANY($1)', [FIXTURE_IDS]); + await server.stop(); + await closeTestPool(); + }); + + it('gir 0 vurderinger og snitt null for en annonse uten vurderinger', async () => { + expect(await fetchListing()).toEqual({ + averageScore: null, + reviewCount: 0, + reviews: [], + }); + }); + + it('lagrer en vurdering og gir annonsen med oppdatert snitt', async () => { + const result = await createReview({ + score: 4, + comment: ' Fin drill, ladet fort. ', + }); + expect(result.errors).toBeUndefined(); + + const payload = result.data?.createReview as { + review: ReviewResult; + listing: ListingResult & { id: string }; + }; + expect(payload.review).toMatchObject({ + score: 4, + comment: 'Fin drill, ladet fort.', + }); + expect(payload.review.id).toMatch(/^review-/); + expect(new Date(payload.review.createdAt).toISOString()).toBe( + payload.review.createdAt, + ); + expect(payload.listing).toMatchObject({ + id: LISTING_ID, + averageScore: 4, + reviewCount: 1, + }); + expect(payload.listing.reviews.map((r) => r.id)).toEqual([ + payload.review.id, + ]); + }); + + it('lagrer en vurdering uten kommentar som tom tekst', async () => { + const result = await createReview({ score: 5 }); + expect(result.errors).toBeUndefined(); + const payload = result.data?.createReview as { review: ReviewResult }; + expect(payload.review.comment).toBe(''); + }); + + it('regner ut snittet av alle vurderingene', async () => { + for (const score of [4, 5, 2]) { + expect((await createReview({ score })).errors).toBeUndefined(); + } + const listing = await fetchListing(); + expect(listing.reviewCount).toBe(3); + expect(listing.averageScore).toBeCloseTo(11 / 3); + }); + + it('holder vurderingene for hver annonse adskilt', async () => { + await createReview({ score: 1 }); + expect(await fetchListing(OTHER_LISTING_ID)).toMatchObject({ + averageScore: null, + reviewCount: 0, + }); + }); + + it('viser de nyeste vurderingene først, og like tidspunkt etter id', async () => { + const db = await pool(); + await db.query( + `INSERT INTO reviews (id, listing_id, score, comment, created_at) VALUES + ('test-review-a', $1, 3, 'Eldst', '2098-01-01T00:00:00Z'), + ('test-review-b', $1, 4, 'Lik tid', '2098-01-02T00:00:00Z'), + ('test-review-c', $1, 5, 'Lik tid', '2098-01-02T00:00:00Z'), + ('test-review-d', $1, 2, 'Nyest', '2098-01-03T00:00:00Z')`, + [LISTING_ID], + ); + const listing = await fetchListing(); + expect(listing.reviews.map((r) => r.id)).toEqual([ + 'test-review-d', + 'test-review-c', + 'test-review-b', + 'test-review-a', + ]); + }); + + it('gir bare så mange vurderinger som limit sier', async () => { + for (const score of [1, 2, 3]) { + await createReview({ score }); + } + const listing = await fetchListing(LISTING_ID, 2); + expect(listing.reviews).toHaveLength(2); + expect(listing.reviewCount).toBe(3); + }); + + it('viser snitt og antall på annonsene i katalogen', async () => { + await createReview({ score: 5 }); + await createReview({ score: 3 }); + const result = await run( + `query ($search: String) { + listings(search: $search, limit: 10) { + items { id averageScore reviewCount } + } + }`, + { search: MARKER }, + ); + expect(result.errors).toBeUndefined(); + const items = ( + result.data?.listings as { + items: { + id: string; + averageScore: number | null; + reviewCount: number; + }[]; + } + ).items; + expect(items).toContainEqual({ + id: LISTING_ID, + averageScore: 4, + reviewCount: 2, + }); + expect(items).toContainEqual({ + id: OTHER_LISTING_ID, + averageScore: null, + reviewCount: 0, + }); + }); + + describe('ugyldige vurderinger', () => { + it.each([0, 6, -1])( + 'avviser score %i og lagrer ingenting', + async (score) => { + const result = await createReview({ score }); + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect((await fetchListing()).reviewCount).toBe(0); + }, + ); + + it('avviser en for lang kommentar', async () => { + const result = await createReview({ + score: 3, + comment: 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 1), + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(result.errors?.[0]?.message).toContain( + `${REVIEW_COMMENT_MAX_LENGTH}`, + ); + }); + + it('avviser en vurdering av en annonse som ikke finnes', async () => { + const result = await run(CREATE_REVIEW, { + input: { listingId: UNKNOWN_LISTING_ID, score: 4 }, + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(result.errors?.[0]?.message).toBe('Fant ikke annonsen.'); + }); + + it.each([0, MAX_REVIEWS_LIMIT + 1])( + 'avviser reviews(limit: %i)', + async (limit) => { + const result = await run(LISTING_WITH_REVIEWS, { + id: LISTING_ID, + limit, + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }, + ); + }); + + describe('databasen', () => { + it('avviser score utenfor 1–5 også uten om backenden', async () => { + const db = await pool(); + await expect( + db.query( + `INSERT INTO reviews (id, listing_id, score) VALUES ('test-review-x', $1, 6)`, + [LISTING_ID], + ), + ).rejects.toThrow(/reviews_score_check/); + }); + + it('sletter vurderingene når annonsen slettes', async () => { + const db = await pool(); + await db.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition) + VALUES ('test-review-temp', 'Midlertidig', 'garden', 'Slettes.', 'Lade', 0, 'good')`, + ); + await db.query( + `INSERT INTO reviews (id, listing_id, score) VALUES ('test-review-temp-1', 'test-review-temp', 4)`, + ); + await db.query(`DELETE FROM listings WHERE id = 'test-review-temp'`); + const remaining = await db.query( + `SELECT 1 FROM reviews WHERE id = 'test-review-temp-1'`, + ); + expect(remaining.rowCount).toBe(0); + }); + }); +}); From b6c8a5cab5da001c66c519311b0c5e883e3569d6 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:04:53 +0100 Subject: [PATCH 05/19] feat(backend): legg til eksempelvurderinger i seed --- backend/src/scripts/seed.ts | 23 ++++++++++ backend/src/seed/reviews.ts | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 backend/src/seed/reviews.ts diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts index 604f21e..97c7f5b 100644 --- a/backend/src/scripts/seed.ts +++ b/backend/src/scripts/seed.ts @@ -1,5 +1,6 @@ import { pool } from '../db/pool.js'; import { seedListings } from '../seed/listings.js'; +import { seedReviews } from '../seed/reviews.js'; const INSERT_SQL = ` INSERT INTO listings ( @@ -17,6 +18,12 @@ const INSERT_SQL = ` ON CONFLICT (id) DO NOTHING `; +const INSERT_REVIEW_SQL = ` + INSERT INTO reviews (id, listing_id, score, comment, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING +`; + async function seed(): Promise { if (seedListings.length !== 10) { throw new Error(`Forventet 10 seed-annonser, fant ${seedListings.length}.`); @@ -45,6 +52,19 @@ async function seed(): Promise { } } + // Vurderingene legges inn etter annonsene, fordi de peker på dem. + let insertedReviews = 0; + for (const review of seedReviews) { + const result = await pool.query(INSERT_REVIEW_SQL, [ + review.id, + review.listingId, + review.score, + review.comment, + review.createdAt, + ]); + insertedReviews += result.rowCount ?? 0; + } + const countResult = await pool.query<{ count: string }>( 'SELECT COUNT(*)::text AS count FROM listings', ); @@ -53,6 +73,9 @@ async function seed(): Promise { console.info( `Seed fullført: ${inserted} nye, ${skipped} hoppet over (finnes fra før). Totalt ${total} rader i listings.`, ); + console.info( + `Vurderinger: ${insertedReviews} nye, ${seedReviews.length - insertedReviews} hoppet over.`, + ); } try { diff --git a/backend/src/seed/reviews.ts b/backend/src/seed/reviews.ts new file mode 100644 index 0000000..ea579ae --- /dev/null +++ b/backend/src/seed/reviews.ts @@ -0,0 +1,83 @@ +/** + * Eksempelvurderinger av noen av seed-annonsene, slik at snittscore og + * vurderinger kan prøves uten å legge dem inn for hånd. Vurderingene er + * oppdiktede og har ingen personopplysninger. + * + * Faste ID-er gjør at seed-skriptet kan kjøres flere ganger uten duplikater. + * Tidspunktene er faste, så rekkefølgen i detaljvisningen er den samme hver + * gang. + */ + +export interface SeedReview { + id: string; + listingId: string; + score: number; + comment: string; + createdAt: string; +} + +export const seedReviews: SeedReview[] = [ + { + id: 'review-seed-001', + listingId: 'listing-001', + score: 5, + comment: 'Batteriet holdt hele dagen. Enkel henting på Moholt.', + createdAt: '2026-09-02T16:20:00.000Z', + }, + { + id: 'review-seed-002', + listingId: 'listing-001', + score: 4, + comment: 'Gjorde jobben med hyllene. Kunne hatt flere bits.', + createdAt: '2026-09-14T09:05:00.000Z', + }, + { + id: 'review-seed-003', + listingId: 'listing-001', + score: 5, + comment: '', + createdAt: '2026-10-01T18:40:00.000Z', + }, + { + id: 'review-seed-004', + listingId: 'listing-002', + score: 4, + comment: 'Kraftig nok til betongvegg. Litt tung å holde over hodet.', + createdAt: '2026-09-21T12:00:00.000Z', + }, + { + id: 'review-seed-005', + listingId: 'listing-003', + score: 3, + comment: 'Saget greit, men bladet var ikke så skarpt som beskrevet.', + createdAt: '2026-09-08T15:30:00.000Z', + }, + { + id: 'review-seed-006', + listingId: 'listing-005', + score: 5, + comment: 'Gratis og i fin stand. Tusen takk!', + createdAt: '2026-09-28T10:15:00.000Z', + }, + { + id: 'review-seed-007', + listingId: 'listing-005', + score: 4, + comment: 'Fin spade til kjøkkenhagen.', + createdAt: '2026-10-05T08:45:00.000Z', + }, + { + id: 'review-seed-008', + listingId: 'listing-008', + score: 5, + comment: 'Alt vi trengte for å male hybelen. Rullene var rene.', + createdAt: '2026-09-17T19:00:00.000Z', + }, + { + id: 'review-seed-009', + listingId: 'listing-010', + score: 2, + comment: 'Tepperenseren virket, men slangen lakk litt.', + createdAt: '2026-10-08T13:25:00.000Z', + }, +]; From a1bde3b9981a1b71bad6ba05241c6e4f9ab7b0fb Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:06:29 +0100 Subject: [PATCH 06/19] feat(graphql): hent snittscore og vurderinger, og legg til createReview i frontend --- src/data/mockListings.ts | 22 +++++++ src/graphql/listingQueries.ts | 35 +++++++++- src/graphql/reviewMutations.ts | 56 ++++++++++++++++ src/test/listingMocks.ts | 113 ++++++++++++++++++++++++++++++++- src/types/listing.ts | 13 +++- src/types/review.ts | 10 +++ 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 src/graphql/reviewMutations.ts create mode 100644 src/types/review.ts diff --git a/src/data/mockListings.ts b/src/data/mockListings.ts index 19abe72..3f9ae6d 100644 --- a/src/data/mockListings.ts +++ b/src/data/mockListings.ts @@ -5,6 +5,8 @@ * * Dataene er valgt for å dekke ulike tilfeller i visningene: gratis og betalt * utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. + * Snittscore og antall vurderinger er de samme som eksempelvurderingene i + * backend/src/seed/reviews.ts gir, og noen annonser har ingen vurderinger. * * Datakilden erstattes senere av GraphQL-backenden. Komponenter bør derfor få * annonser gjennom props i stedet for å importere denne filen direkte. @@ -36,6 +38,8 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: ['2 batterier', 'Lader', 'Bitssett'], imageUrl: drillImage, + averageScore: 14 / 3, + reviewCount: 3, }, { id: 'listing-002', @@ -48,6 +52,8 @@ export const mockListings: Listing[] = [ condition: 'likeNew', accessories: ['Borsett for betong', 'Koffert', 'Dybdestopper'], imageUrl: drillImage, + averageScore: 4, + reviewCount: 1, }, { id: 'listing-003', @@ -59,6 +65,8 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: [], imageUrl: handsawImage, + averageScore: 3, + reviewCount: 1, }, { id: 'listing-004', @@ -71,6 +79,8 @@ export const mockListings: Listing[] = [ condition: 'worn', accessories: ['Målebånd', 'Unbrakonøkler'], imageUrl: null, + averageScore: null, + reviewCount: 0, }, { id: 'listing-005', @@ -82,6 +92,8 @@ export const mockListings: Listing[] = [ condition: 'worn', accessories: [], imageUrl: spadeImage, + averageScore: 4.5, + reviewCount: 2, }, { id: 'listing-006', @@ -94,6 +106,8 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: ['Rive', 'Arbeidshansker'], imageUrl: spadeImage, + averageScore: null, + reviewCount: 0, }, { id: 'listing-007', @@ -106,6 +120,8 @@ export const mockListings: Listing[] = [ condition: 'likeNew', accessories: ['2 ruller', 'Malebrett', 'Teleskopskaft'], imageUrl: paintRollerImage, + averageScore: null, + reviewCount: 0, }, { id: 'listing-008', @@ -124,6 +140,8 @@ export const mockListings: Listing[] = [ 'Maskeringstape', ], imageUrl: paintRollerImage, + averageScore: 5, + reviewCount: 1, }, { id: 'listing-009', @@ -136,6 +154,8 @@ export const mockListings: Listing[] = [ condition: 'likeNew', accessories: ['Stativ', 'Batterier'], imageUrl: null, + averageScore: null, + reviewCount: 0, }, { id: 'listing-010', @@ -148,5 +168,7 @@ export const mockListings: Listing[] = [ condition: 'good', accessories: ['Slange', '2 munnstykker'], imageUrl: null, + averageScore: 2, + reviewCount: 1, }, ]; diff --git a/src/graphql/listingQueries.ts b/src/graphql/listingQueries.ts index 5d345e9..1ca5191 100644 --- a/src/graphql/listingQueries.ts +++ b/src/graphql/listingQueries.ts @@ -5,6 +5,7 @@ import type { ToolCategory, ToolCondition, } from '../types/listing.ts'; +import type { Review } from '../types/review.ts'; import { DEFAULT_LISTING_SORT, type ListingSort, @@ -148,6 +149,8 @@ export const LISTINGS_PAGE_QUERY: TypedDocumentNode< area dailyPrice imageUrl + averageScore + reviewCount } totalCount hasNextPage @@ -173,8 +176,36 @@ export const LISTING_AREAS_QUERY: TypedDocumentNode< } `; +/** Antall vurderinger detaljvisningen viser, de nyeste først. */ +export const REVIEWS_LIMIT = 10; + +/** + * Snitt, antall og de nyeste vurderingene av en annonse. Brukes både i + * detaljspørringen og i svaret på createReview. Feltet reviews har samme + * argument begge steder, og Apollo lagrer annonsen under samme id, så svaret + * på en ny vurdering oppdaterer detaljvisningen uten en ny spørring. + */ +export const LISTING_REVIEWS_FRAGMENT = gql` + fragment ListingReviews on Listing { + id + averageScore + reviewCount + reviews(limit: ${REVIEWS_LIMIT}) { + id + score + comment + createdAt + } + } +`; + +/** Annonsen i detaljvisningen, med de nyeste vurderingene. */ +export interface ListingDetail extends Listing { + reviews: Review[]; +} + export interface ListingData { - listing: Listing | null; + listing: ListingDetail | null; } export interface ListingVariables { @@ -195,6 +226,8 @@ export const LISTING_QUERY: TypedDocumentNode = condition accessories imageUrl + ...ListingReviews } } + ${LISTING_REVIEWS_FRAGMENT} `; diff --git a/src/graphql/reviewMutations.ts b/src/graphql/reviewMutations.ts new file mode 100644 index 0000000..e25f448 --- /dev/null +++ b/src/graphql/reviewMutations.ts @@ -0,0 +1,56 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; +import type { Review } from '../types/review.ts'; +import { + LISTING_REVIEWS_FRAGMENT, + type ListingDetail, +} from './listingQueries.ts'; + +/** Laveste og høyeste score. Samme grenser som i backend. */ +export const REVIEW_SCORE_MIN = 1; +export const REVIEW_SCORE_MAX = 5; + +/** Lengste kommentar. Samme grense som REVIEW_COMMENT_MAX_LENGTH i backend. */ +export const REVIEW_COMMENT_MAX_LENGTH = 500; + +export interface CreateReviewInput { + listingId: string; + score: number; + /** Utelates når vurderingen ikke har kommentar. */ + comment?: string; +} + +export interface CreateReviewData { + createReview: { + review: Pick; + listing: Pick< + ListingDetail, + 'id' | 'averageScore' | 'reviewCount' | 'reviews' + >; + }; +} + +export interface CreateReviewVariables { + input: CreateReviewInput; +} + +/** + * Lagrer en vurdering. Svaret har annonsen med nytt snitt, antall og liste + * over vurderinger, i samme form som detaljspørringen. Apollo oppdaterer da + * detaljvisningen og annonsekortet i cachen, uten at noe hentes på nytt. + */ +export const CREATE_REVIEW_MUTATION: TypedDocumentNode< + CreateReviewData, + CreateReviewVariables +> = gql` + mutation CreateReview($input: CreateReviewInput!) { + createReview(input: $input) { + review { + id + } + listing { + ...ListingReviews + } + } + } + ${LISTING_REVIEWS_FRAGMENT} +`; diff --git a/src/test/listingMocks.ts b/src/test/listingMocks.ts index ff7a3ec..1442fc3 100644 --- a/src/test/listingMocks.ts +++ b/src/test/listingMocks.ts @@ -11,7 +11,12 @@ import { listingsPageVariables, type CatalogQuery, } from '../graphql/listingQueries.ts'; +import { + CREATE_REVIEW_MUTATION, + type CreateReviewInput, +} from '../graphql/reviewMutations.ts'; import type { Listing } from '../types/listing.ts'; +import type { Review } from '../types/review.ts'; /** * Mockede GraphQL-svar for testene. Annonsene er de samme eksemplene som i @@ -20,7 +25,16 @@ import type { Listing } from '../types/listing.ts'; */ function toSummary(listing: Listing) { - const { id, title, category, area, dailyPrice, imageUrl } = listing; + const { + id, + title, + category, + area, + dailyPrice, + imageUrl, + averageScore, + reviewCount, + } = listing; return { __typename: 'Listing' as const, id, @@ -29,6 +43,8 @@ function toSummary(listing: Listing) { area, dailyPrice, imageUrl, + averageScore, + reviewCount, }; } @@ -86,21 +102,112 @@ export function listingAreasMock( }; } -/** Svar på detaljspørringen. null betyr at annonsen ikke finnes. */ +/** Vurderinger i samme form som svaret fra GraphQL. */ +function withReviewTypename(reviews: Review[]) { + return reviews.map((review) => ({ __typename: 'Review', ...review })); +} + +/** + * Svar på detaljspørringen. null betyr at annonsen ikke finnes. Vurderingene + * er tomme hvis ikke testen sender med noen. + */ export function listingMock( id: string, listing: Listing | null = mockListings.find((item) => item.id === id) ?? null, + reviews: Review[] = [], ): MockLink.MockedResponse { return { request: { query: LISTING_QUERY, variables: { id } }, result: { data: { - listing: listing && { __typename: 'Listing', ...listing }, + listing: listing && { + __typename: 'Listing', + ...listing, + reviews: withReviewTypename(reviews), + }, + }, + }, + }; +} + +/** Eksempelvurderinger, nyeste først, slik serveren sorterer dem. */ +export const mockReviews: Review[] = [ + { + id: 'review-3', + score: 5, + comment: '', + createdAt: '2026-10-01T18:40:00.000Z', + }, + { + id: 'review-2', + score: 4, + comment: 'Gjorde jobben med hyllene. Kunne hatt flere bits.', + createdAt: '2026-09-14T09:05:00.000Z', + }, + { + id: 'review-1', + score: 5, + comment: 'Batteriet holdt hele dagen.', + createdAt: '2026-09-02T16:20:00.000Z', + }, +]; + +export function createReviewRequest( + input: CreateReviewInput, +): MockLink.MockedResponse['request'] { + return { query: CREATE_REVIEW_MUTATION, variables: { input } }; +} + +interface CreateReviewMockOptions { + /** Vurderingene serveren svarer med, nyeste først. */ + reviews: Review[]; + averageScore: number; +} + +/** + * Vellykket createReview. Svaret har annonsen med nytt snitt og ny liste, slik + * serveren gir den. delay holder svaret igjen, så testene kan se at knappen + * er deaktivert mens vurderingen lagres. + */ +export function createReviewMock( + input: CreateReviewInput, + { reviews, averageScore }: CreateReviewMockOptions, +): MockLink.MockedResponse { + const [newest] = reviews; + return { + request: createReviewRequest(input), + delay: 20, + result: { + data: { + createReview: { + __typename: 'CreateReviewPayload', + review: { __typename: 'Review', id: newest?.id ?? 'review-ny' }, + listing: { + __typename: 'Listing', + id: input.listingId, + averageScore, + reviewCount: reviews.length, + reviews: withReviewTypename(reviews), + }, + }, }, }, }; } +/** Serveren avviser vurderingen, som når valideringen i backenden feiler. */ +export function createReviewRejectedMock( + input: CreateReviewInput, + message: string, +): MockLink.MockedResponse { + return { + request: createReviewRequest(input), + result: { + errors: [{ message, extensions: { code: 'BAD_USER_INPUT' } }], + }, + }; +} + /** Nettverksfeil, som når backenden ikke svarer. */ export function networkErrorMock( request: MockLink.MockedResponse['request'], diff --git a/src/types/listing.ts b/src/types/listing.ts index b2ec889..b2d74bd 100644 --- a/src/types/listing.ts +++ b/src/types/listing.ts @@ -25,10 +25,21 @@ export interface Listing { accessories: string[]; /** Adresse til bilde av verktøyet, eller null når annonsen mangler bilde. */ imageUrl: string | null; + /** Snittet av vurderingene, fra 1 til 5, eller null uten vurderinger. */ + averageScore: number | null; + /** Antall vurderinger av annonsen. */ + reviewCount: number; } /** Feltene annonsekortet i katalogen trenger. */ export type ListingSummary = Pick< Listing, - 'id' | 'title' | 'category' | 'area' | 'dailyPrice' | 'imageUrl' + | 'id' + | 'title' + | 'category' + | 'area' + | 'dailyPrice' + | 'imageUrl' + | 'averageScore' + | 'reviewCount' >; diff --git a/src/types/review.ts b/src/types/review.ts new file mode 100644 index 0000000..f2d6f29 --- /dev/null +++ b/src/types/review.ts @@ -0,0 +1,10 @@ +/** En vurdering av en annonse, slik backenden gir den. */ +export interface Review { + id: string; + /** Fra 1 til 5, der 5 er best. */ + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; + /** Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC. */ + createdAt: string; +} From 11c767ecb7388e87abab4c26224be1c143e5ccbc Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:07:45 +0100 Subject: [PATCH 07/19] =?UTF-8?q?feat(components):=20vis=20snittscore=20me?= =?UTF-8?q?d=20tekst=20og=20stjerner=20p=C3=A5=20annonsekortet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ListingCard/ListingCard.tsx | 5 ++ .../ReviewScore/ReviewScore.module.css | 32 ++++++++++++ .../ReviewScore/ReviewScore.test.tsx | 34 +++++++++++++ src/components/ReviewScore/ReviewScore.tsx | 40 +++++++++++++++ src/utils/reviewFormat.test.ts | 49 +++++++++++++++++++ src/utils/reviewFormat.ts | 44 +++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 src/components/ReviewScore/ReviewScore.module.css create mode 100644 src/components/ReviewScore/ReviewScore.test.tsx create mode 100644 src/components/ReviewScore/ReviewScore.tsx create mode 100644 src/utils/reviewFormat.test.ts create mode 100644 src/utils/reviewFormat.ts diff --git a/src/components/ListingCard/ListingCard.tsx b/src/components/ListingCard/ListingCard.tsx index 6c17c19..6cc3a62 100644 --- a/src/components/ListingCard/ListingCard.tsx +++ b/src/components/ListingCard/ListingCard.tsx @@ -4,6 +4,7 @@ import { catalogReturnState } from '../../utils/catalogPage.ts'; import { categoryLabels } from '../../utils/listingFormat.ts'; import ListingImage from '../ListingImage/ListingImage.tsx'; import PriceTag from '../PriceTag/PriceTag.tsx'; +import ReviewScore from '../ReviewScore/ReviewScore.tsx'; import styles from './ListingCard.module.css'; interface ListingCardProps { @@ -37,6 +38,10 @@ function ListingCard({ listing }: ListingCardProps) {

{categoryLabels[listing.category]} · {listing.area}

+ diff --git a/src/components/ReviewScore/ReviewScore.module.css b/src/components/ReviewScore/ReviewScore.module.css new file mode 100644 index 0000000..672f0aa --- /dev/null +++ b/src/components/ReviewScore/ReviewScore.module.css @@ -0,0 +1,32 @@ +.score { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin: 0; + font-size: var(--font-size-sm); +} + +/* De fylte stjernene ligger over de tomme og klippes til snittet. */ +.stars { + position: relative; + display: inline-block; + line-height: 1; + letter-spacing: 0.1em; + white-space: nowrap; +} + +.empty { + color: var(--color-border-strong); +} + +.filled { + position: absolute; + inset: 0 auto 0 0; + overflow: hidden; + color: var(--color-accent); +} + +.none { + color: var(--color-text-muted); +} diff --git a/src/components/ReviewScore/ReviewScore.test.tsx b/src/components/ReviewScore/ReviewScore.test.tsx new file mode 100644 index 0000000..aba2169 --- /dev/null +++ b/src/components/ReviewScore/ReviewScore.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import ReviewScore from './ReviewScore.tsx'; + +describe('ReviewScore', () => { + it('viser snittet som tekst ved siden av stjernene', () => { + const { container } = render( + , + ); + expect(screen.getByText('4,7 av 5 (3 vurderinger)')).toBeVisible(); + // Stjernene er pynt og skal ikke leses opp. + const stars = container.querySelector('[aria-hidden="true"]'); + expect(stars).toHaveTextContent('★'); + }); + + it('leser bare opp teksten, ikke stjernesymbolene', () => { + const { container } = render( + , + ); + const visibleText = [...container.querySelectorAll('span')] + .filter((span) => !span.closest('[aria-hidden="true"]')) + .map((span) => span.textContent) + .join(''); + expect(visibleText).toBe('4,0 av 5 (1 vurdering)'); + }); + + it('viser «Ingen vurderinger ennå» uten stjerner', () => { + const { container } = render( + , + ); + expect(screen.getByText('Ingen vurderinger ennå')).toBeVisible(); + expect(container).not.toHaveTextContent('★'); + }); +}); diff --git a/src/components/ReviewScore/ReviewScore.tsx b/src/components/ReviewScore/ReviewScore.tsx new file mode 100644 index 0000000..c2cd106 --- /dev/null +++ b/src/components/ReviewScore/ReviewScore.tsx @@ -0,0 +1,40 @@ +import { REVIEW_SCORE_MAX } from '../../graphql/reviewMutations.ts'; +import { scoreSummary } from '../../utils/reviewFormat.ts'; +import styles from './ReviewScore.module.css'; + +interface ReviewScoreProps { + averageScore: number | null; + reviewCount: number; +} + +/** + * Snittscore som stjerner og tekst, for eksempel «★★★★☆ 4,7 av 5 (3 + * vurderinger)». Stjernene er bare pynt og skjules for skjermlesere. Teksten + * står alltid ved siden av, så scoren ikke bare vises med symboler eller + * farge. Uten vurderinger vises bare «Ingen vurderinger ennå». + */ +function ReviewScore({ averageScore, reviewCount }: ReviewScoreProps) { + const hasScore = averageScore !== null && reviewCount > 0; + + return ( +

+ {hasScore && ( + + )} + + {scoreSummary(averageScore, reviewCount)} + +

+ ); +} + +export default ReviewScore; diff --git a/src/utils/reviewFormat.test.ts b/src/utils/reviewFormat.test.ts new file mode 100644 index 0000000..4066499 --- /dev/null +++ b/src/utils/reviewFormat.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + formatReviewDate, + reviewCountLabel, + scoreLabel, + scoreSummary, +} from './reviewFormat.ts'; + +describe('scoreSummary', () => { + it('viser snittet med én desimal og antall vurderinger', () => { + expect(scoreSummary(14 / 3, 3)).toBe('4,7 av 5 (3 vurderinger)'); + }); + + it('viser én desimal også for hele tall', () => { + expect(scoreSummary(4, 1)).toBe('4,0 av 5 (1 vurdering)'); + }); + + it('sier fra når annonsen ikke har vurderinger', () => { + expect(scoreSummary(null, 0)).toBe('Ingen vurderinger ennå'); + }); +}); + +describe('reviewCountLabel', () => { + it('bøyer ordet etter antallet', () => { + expect(reviewCountLabel(1)).toBe('1 vurdering'); + expect(reviewCountLabel(0)).toBe('0 vurderinger'); + expect(reviewCountLabel(12)).toBe('12 vurderinger'); + }); +}); + +describe('scoreLabel', () => { + it('viser scoren av 5', () => { + expect(scoreLabel(4)).toBe('4 av 5'); + }); +}); + +describe('formatReviewDate', () => { + it('viser datoen på norsk i norsk tid', () => { + expect(formatReviewDate('2026-09-02T16:20:00.000Z')).toBe( + '2. september 2026', + ); + }); + + it('bruker norsk tid, ikke UTC, rett før midnatt', () => { + expect(formatReviewDate('2026-09-02T22:30:00.000Z')).toBe( + '3. september 2026', + ); + }); +}); diff --git a/src/utils/reviewFormat.ts b/src/utils/reviewFormat.ts new file mode 100644 index 0000000..bd7e6a5 --- /dev/null +++ b/src/utils/reviewFormat.ts @@ -0,0 +1,44 @@ +import { REVIEW_SCORE_MAX } from '../graphql/reviewMutations.ts'; + +/** Én desimal med norsk desimalkomma, for eksempel «4,7». */ +const scoreFormatter = new Intl.NumberFormat('nb-NO', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}); + +const dateFormatter = new Intl.DateTimeFormat('nb-NO', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'Europe/Oslo', +}); + +/** «1 vurdering» eller «3 vurderinger». */ +export function reviewCountLabel(count: number): string { + return count === 1 ? '1 vurdering' : `${count} vurderinger`; +} + +/** + * Snittscore og antall som tekst, for eksempel «4,7 av 5 (3 vurderinger)». + * Uten vurderinger blir teksten «Ingen vurderinger ennå». Teksten står ved + * siden av stjernene, så scoren ikke bare vises med symboler. + */ +export function scoreSummary( + averageScore: number | null, + reviewCount: number, +): string { + if (averageScore === null || reviewCount === 0) { + return 'Ingen vurderinger ennå'; + } + return `${scoreFormatter.format(averageScore)} av ${REVIEW_SCORE_MAX} (${reviewCountLabel(reviewCount)})`; +} + +/** Én score som tekst, for eksempel «4 av 5». */ +export function scoreLabel(score: number): string { + return `${score} av ${REVIEW_SCORE_MAX}`; +} + +/** Datoen en vurdering ble gitt, for eksempel «2. september 2026». */ +export function formatReviewDate(createdAt: string): string { + return dateFormatter.format(new Date(createdAt)); +} From bf6b7926140b41e8a1081425ebb1196689fa245a Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:09:28 +0100 Subject: [PATCH 08/19] =?UTF-8?q?feat(components):=20lag=20skjema=20for=20?= =?UTF-8?q?=C3=A5=20gi=20en=20vurdering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewForm/ReviewForm.module.css | 76 ++++++++ src/components/ReviewForm/ReviewForm.test.tsx | 154 ++++++++++++++++ src/components/ReviewForm/ReviewForm.tsx | 171 ++++++++++++++++++ src/utils/reviewForm.test.ts | 51 ++++++ src/utils/reviewForm.ts | 68 +++++++ 5 files changed, 520 insertions(+) create mode 100644 src/components/ReviewForm/ReviewForm.module.css create mode 100644 src/components/ReviewForm/ReviewForm.test.tsx create mode 100644 src/components/ReviewForm/ReviewForm.tsx create mode 100644 src/utils/reviewForm.test.ts create mode 100644 src/utils/reviewForm.ts diff --git a/src/components/ReviewForm/ReviewForm.module.css b/src/components/ReviewForm/ReviewForm.module.css new file mode 100644 index 0000000..4b98252 --- /dev/null +++ b/src/components/ReviewForm/ReviewForm.module.css @@ -0,0 +1,76 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: 32rem; +} + +.scores { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: 0; + padding: 0; + border: 0; +} + +.scores legend { + margin-bottom: var(--space-2); + padding: 0; +} + +/* Valgene står på rad når det er plass, og under hverandre på mobil. */ +.options { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* Hele etiketten er klikkbar og stor nok til å treffe med fingeren. */ +.option { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: var(--tap-target); + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface); + cursor: pointer; +} + +.option input { + width: 1.25rem; + height: 1.25rem; + margin: 0; + accent-color: var(--color-accent); +} + +/* Valgt score får tykkere kant i tillegg til den fylte radioknappen. */ +.option:has(input:checked) { + border: 2px solid var(--color-accent); +} + +.option:has(input[aria-invalid='true']) { + border-color: var(--color-error); +} + +.status p, +.error p { + margin: 0; +} + +/* Bekreftelsen får ramme bare når den har innhold. */ +.status:not(:empty) { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.error { + padding: var(--space-3) var(--space-4); + border: 2px solid var(--color-error); + border-radius: var(--radius-md); + background: var(--color-surface); +} diff --git a/src/components/ReviewForm/ReviewForm.test.tsx b/src/components/ReviewForm/ReviewForm.test.tsx new file mode 100644 index 0000000..85e852a --- /dev/null +++ b/src/components/ReviewForm/ReviewForm.test.tsx @@ -0,0 +1,154 @@ +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { + createReviewMock, + createReviewRejectedMock, + createReviewRequest, + mockReviews, + networkErrorMock, +} from '../../test/listingMocks.ts'; +import ReviewForm from './ReviewForm.tsx'; + +const LISTING_ID = 'listing-001'; + +function renderForm(mocks: MockLink.MockedResponse[] = []) { + return render( + + + , + ); +} + +function scoreGroup() { + return screen.getByRole('group', { name: 'Vurdering' }); +} + +function commentField() { + return screen.getByRole('textbox', { name: 'Kommentar (valgfri)' }); +} + +function submitButton() { + return screen.getByRole('button', { name: /Legg ut vurdering|Lagrer/ }); +} + +describe('ReviewForm', () => { + it('har etiketter på alle valgene og feltet', () => { + renderForm(); + const radios = within(scoreGroup()).getAllByRole('radio'); + expect(radios.map((radio) => radio.getAttribute('value'))).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]); + expect(screen.getByRole('radio', { name: '4 – Bra' })).not.toBeChecked(); + expect(commentField()).toHaveAccessibleDescription(/Høyst 500 tegn/); + }); + + it('kan velge score med piltastene', async () => { + renderForm(); + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.keyboard('{ArrowRight}'); + expect(screen.getByRole('radio', { name: '4 – Bra' })).toBeChecked(); + }); + + it('viser feil ved scoren og flytter fokus dit når ingen score er valgt', async () => { + // Ingen mocks: en forespørsel ville gitt feil i stedet for valideringsfeil. + renderForm(); + await userEvent.click(submitButton()); + + expect(scoreGroup()).toHaveAccessibleDescription( + 'Velg en vurdering fra 1 til 5.', + ); + expect( + screen.getByRole('radio', { name: '1 – Svært dårlig' }), + ).toHaveFocus(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('viser feil ved kommentaren når den er for lang', async () => { + renderForm(); + await userEvent.click(screen.getByRole('radio', { name: '4 – Bra' })); + await userEvent.click(commentField()); + await userEvent.paste('a'.repeat(501)); + await userEvent.click(submitButton()); + + expect(commentField()).toHaveAttribute('aria-invalid', 'true'); + expect(commentField()).toHaveAccessibleDescription( + /høyst 500 tegn\. Nå har den 501\./, + ); + expect(commentField()).toHaveFocus(); + }); + + it('lagrer vurderingen, bekrefter og tømmer skjemaet', async () => { + renderForm([ + createReviewMock( + { listingId: LISTING_ID, score: 4, comment: 'Fin drill.' }, + { reviews: mockReviews, averageScore: 14 / 3 }, + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '4 – Bra' })); + await userEvent.type(commentField(), ' Fin drill. '); + await userEvent.click(submitButton()); + + expect(submitButton()).toBeDisabled(); + expect(submitButton()).toHaveTextContent('Lagrer'); + + expect( + await screen.findByText( + 'Takk! Vurderingen din er lagt ut og vises over.', + ), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Takk!'); + expect(screen.getByRole('radio', { name: '4 – Bra' })).not.toBeChecked(); + expect(commentField()).toHaveValue(''); + expect(submitButton()).toBeEnabled(); + }); + + it('sender ikke med en tom kommentar', async () => { + renderForm([ + createReviewMock( + { listingId: LISTING_ID, score: 5 }, + { reviews: mockReviews, averageScore: 14 / 3 }, + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '5 – Svært bra' })); + await userEvent.type(commentField(), ' '); + await userEvent.click(submitButton()); + expect(await screen.findByText(/Takk!/)).toBeInTheDocument(); + }); + + it('viser feilen fra serveren og beholder det som er fylt ut', async () => { + renderForm([ + createReviewRejectedMock( + { listingId: LISTING_ID, score: 2 }, + 'Fant ikke annonsen.', + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '2 – Dårlig' })); + await userEvent.click(submitButton()); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Vurderingen ble ikke lagret. Fant ikke annonsen.', + ); + expect(screen.getByRole('radio', { name: '2 – Dårlig' })).toBeChecked(); + }); + + it('forteller når serveren ikke svarer', async () => { + renderForm([ + networkErrorMock( + createReviewRequest({ listingId: LISTING_ID, score: 3 }), + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.click(submitButton()); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Vi fikk ikke kontakt med serveren', + ); + }); +}); diff --git a/src/components/ReviewForm/ReviewForm.tsx b/src/components/ReviewForm/ReviewForm.tsx new file mode 100644 index 0000000..e502aca --- /dev/null +++ b/src/components/ReviewForm/ReviewForm.tsx @@ -0,0 +1,171 @@ +import { useMutation } from '@apollo/client/react'; +import { useEffect, useId, useRef, useState, type SubmitEvent } from 'react'; +import { serverValidationMessage } from '../../graphql/listingMutations.ts'; +import { + CREATE_REVIEW_MUTATION, + REVIEW_COMMENT_MAX_LENGTH, +} from '../../graphql/reviewMutations.ts'; +import { + emptyReviewForm, + scoreOptions, + toCreateReviewInput, + validateReviewForm, + type ReviewFormValues, +} from '../../utils/reviewForm.ts'; +import Button from '../Button/Button.tsx'; +import TextAreaField from '../TextAreaField/TextAreaField.tsx'; +import fieldStyles from '../TextField/TextField.module.css'; +import styles from './ReviewForm.module.css'; + +interface ReviewFormProps { + listingId: string; +} + +/** Resultatet av siste innsending, som vises under knappen. */ +type SubmitResult = + | { kind: 'created' } + | { kind: 'rejected'; message: string } + | { kind: 'networkError' }; + +/** + * Skjema for å gi en annonse en vurdering fra 1 til 5 med en valgfri + * kommentar. Svaret fra createReview har annonsen med nytt snitt og ny liste + * over vurderinger, og Apollo oppdaterer visningen fra det. Den nye + * vurderingen vises derfor med en gang, uten at siden lastes på nytt. + * + * Scoren velges med radioknapper i en gruppe, så den kan velges med + * piltastene. Feil vises som tekst ved feltet etter første forsøk på + * innsending, og fokus flyttes til første felt med feil. + */ +function ReviewForm({ listingId }: ReviewFormProps) { + const id = useId(); + const scoreErrorId = `${id}-score-error`; + const [values, setValues] = useState(emptyReviewForm); + const [submitCount, setSubmitCount] = useState(0); + const [result, setResult] = useState(null); + const formRef = useRef(null); + + const [createReview, { loading: isSaving }] = useMutation( + CREATE_REVIEW_MUTATION, + ); + + const errors = validateReviewForm(values); + const isValid = Object.keys(errors).length === 0; + const visibleErrors = submitCount > 0 ? errors : {}; + + useEffect(() => { + if (submitCount === 0) { + return; + } + formRef.current + ?.querySelector('[aria-invalid="true"]') + ?.focus(); + }, [submitCount]); + + function updateValues(changes: Partial) { + setValues((current) => ({ ...current, ...changes })); + // En gammel bekreftelse eller feil gjelder ikke lenger når skjemaet endres. + setResult(null); + } + + async function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + // Enter i et felt kan sende skjemaet selv om knappen er deaktivert. + if (isSaving) { + return; + } + setSubmitCount((count) => count + 1); + setResult(null); + if (!isValid) { + return; + } + + try { + await createReview({ + variables: { input: toCreateReviewInput(listingId, values) }, + }); + setResult({ kind: 'created' }); + setValues(emptyReviewForm); + setSubmitCount(0); + } catch (error) { + // Det brukeren har fylt ut, beholdes, så hen kan prøve igjen. + const message = serverValidationMessage(error); + setResult( + message ? { kind: 'rejected', message } : { kind: 'networkError' }, + ); + } + } + + return ( +
+
+ Vurdering +
+ {scoreOptions.map(({ score, label }) => ( + + ))} +
+ {visibleErrors.score && ( +

+ {visibleErrors.score} +

+ )} +
+ + updateValues({ comment: event.target.value })} + /> + +
+ +
+ + {/* Alltid i DOM-en, slik at skjermlesere leser opp ny tekst i den. */} +
+ {result?.kind === 'created' && ( +

Takk! Vurderingen din er lagt ut og vises over.

+ )} +
+ + {result && result.kind !== 'created' && ( +
+

+ Vurderingen ble ikke lagret.{' '} + {result.kind === 'rejected' + ? result.message + : 'Vi fikk ikke kontakt med serveren. Det du har fylt ut, er tatt vare på, så du kan prøve igjen om litt.'} +

+
+ )} + + ); +} + +export default ReviewForm; diff --git a/src/utils/reviewForm.test.ts b/src/utils/reviewForm.test.ts new file mode 100644 index 0000000..887e216 --- /dev/null +++ b/src/utils/reviewForm.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { REVIEW_COMMENT_MAX_LENGTH } from '../graphql/reviewMutations.ts'; +import { + emptyReviewForm, + toCreateReviewInput, + validateReviewForm, +} from './reviewForm.ts'; + +describe('validateReviewForm', () => { + it('krever at en score er valgt', () => { + expect(validateReviewForm(emptyReviewForm)).toEqual({ + score: 'Velg en vurdering fra 1 til 5.', + }); + }); + + it('godtar en score uten kommentar', () => { + expect(validateReviewForm({ score: 4, comment: '' })).toEqual({}); + }); + + it('godtar en kommentar på nøyaktig maksimal lengde', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH); + expect(validateReviewForm({ score: 4, comment })).toEqual({}); + }); + + it('teller ikke mellomrom i endene med i lengden', () => { + const comment = ` ${'a'.repeat(REVIEW_COMMENT_MAX_LENGTH)} `; + expect(validateReviewForm({ score: 4, comment })).toEqual({}); + }); + + it('avviser en for lang kommentar og sier hvor lang den er', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 3); + expect(validateReviewForm({ score: 4, comment }).comment).toBe( + 'Kommentaren kan ha høyst 500 tegn. Nå har den 503.', + ); + }); +}); + +describe('toCreateReviewInput', () => { + it('sender med kommentaren uten mellomrom i endene', () => { + expect( + toCreateReviewInput('listing-001', { score: 5, comment: ' Topp! ' }), + ).toEqual({ listingId: 'listing-001', score: 5, comment: 'Topp!' }); + }); + + it.each(['', ' '])('utelater en tom kommentar («%s»)', (comment) => { + expect(toCreateReviewInput('listing-001', { score: 3, comment })).toEqual({ + listingId: 'listing-001', + score: 3, + }); + }); +}); diff --git a/src/utils/reviewForm.ts b/src/utils/reviewForm.ts new file mode 100644 index 0000000..d9c5e9f --- /dev/null +++ b/src/utils/reviewForm.ts @@ -0,0 +1,68 @@ +/** + * Validering av skjemaet for vurderinger. Serveren gjør den samme kontrollen + * når vurderingen lagres (backend/src/validation/createReviewInput.ts). + */ + +import { + REVIEW_COMMENT_MAX_LENGTH, + REVIEW_SCORE_MAX, + REVIEW_SCORE_MIN, + type CreateReviewInput, +} from '../graphql/reviewMutations.ts'; + +export interface ReviewFormValues { + /** null til brukeren har valgt en score. */ + score: number | null; + comment: string; +} + +export type ReviewFormErrors = Partial>; + +export const emptyReviewForm: ReviewFormValues = { score: null, comment: '' }; + +/** Valgene i skjemaet, med en kort tekst som forklarer hver score. */ +export const scoreOptions: { score: number; label: string }[] = [ + { score: 1, label: 'Svært dårlig' }, + { score: 2, label: 'Dårlig' }, + { score: 3, label: 'Grei' }, + { score: 4, label: 'Bra' }, + { score: 5, label: 'Svært bra' }, +]; + +export function validateReviewForm(values: ReviewFormValues): ReviewFormErrors { + const errors: ReviewFormErrors = {}; + + if ( + values.score === null || + values.score < REVIEW_SCORE_MIN || + values.score > REVIEW_SCORE_MAX + ) { + errors.score = `Velg en vurdering fra ${REVIEW_SCORE_MIN} til ${REVIEW_SCORE_MAX}.`; + } + + const commentLength = values.comment.trim().length; + if (commentLength > REVIEW_COMMENT_MAX_LENGTH) { + errors.comment = `Kommentaren kan ha høyst ${REVIEW_COMMENT_MAX_LENGTH} tegn. Nå har den ${commentLength}.`; + } + + return errors; +} + +/** + * Gjør skjemaverdiene om til input for createReview. Skal bare kalles når + * skjemaet er gyldig. En tom kommentar sendes ikke med. + */ +export function toCreateReviewInput( + listingId: string, + values: ReviewFormValues, +): CreateReviewInput { + const input: CreateReviewInput = { + listingId, + score: values.score ?? REVIEW_SCORE_MIN, + }; + const comment = values.comment.trim(); + if (comment) { + input.comment = comment; + } + return input; +} From 7cc6c4f43cf631b34dd93bb21ed911769be88ad3 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:10:53 +0100 Subject: [PATCH 09/19] =?UTF-8?q?test(src):=20gj=C3=B8r=20testen=20for=20l?= =?UTF-8?q?asting=20av=20nytt=20s=C3=B8k=20mindre=20avhengig=20av=20tid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index d4d058f..f72b6b0 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -225,7 +225,9 @@ describe('søk i katalogen', () => { search: 'drill', items: drill, totalCount: 1, - delay: 50, + // Lang nok til at svaret ikke rekker å komme før sjekken under, også + // når mange tester kjører samtidig. + delay: 300, }), ]); await findCards(); From dbe1d8f34f83c53f1ba4df588985f2698ea0027b Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:11:30 +0100 Subject: [PATCH 10/19] feat(pages): vis vurderinger og skjema for nye vurderinger i detaljvisningen --- .../ReviewList/ReviewList.module.css | 57 ++++++++++++++++ src/components/ReviewList/ReviewList.test.tsx | 46 +++++++++++++ src/components/ReviewList/ReviewList.tsx | 65 +++++++++++++++++++ .../ListingDetailPage.module.css | 18 +++++ .../ListingDetailPage/ListingDetailPage.tsx | 30 ++++++++- 5 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/components/ReviewList/ReviewList.module.css create mode 100644 src/components/ReviewList/ReviewList.test.tsx create mode 100644 src/components/ReviewList/ReviewList.tsx diff --git a/src/components/ReviewList/ReviewList.module.css b/src/components/ReviewList/ReviewList.module.css new file mode 100644 index 0000000..6d17a0a --- /dev/null +++ b/src/components/ReviewList/ReviewList.module.css @@ -0,0 +1,57 @@ +.list { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.review { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin: 0; +} + +.stars { + color: var(--color-accent); + letter-spacing: 0.1em; + white-space: nowrap; +} + +.emptyStars { + color: var(--color-border-strong); +} + +.date, +.more, +.empty, +.noComment { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.comment, +.noComment, +.more, +.empty { + margin: 0; +} + +.comment { + /* Lange ord og lenker brytes i stedet for å gi horisontal scrolling. */ + overflow-wrap: anywhere; + white-space: pre-line; +} diff --git a/src/components/ReviewList/ReviewList.test.tsx b/src/components/ReviewList/ReviewList.test.tsx new file mode 100644 index 0000000..26a316c --- /dev/null +++ b/src/components/ReviewList/ReviewList.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { mockReviews } from '../../test/listingMocks.ts'; +import ReviewList from './ReviewList.tsx'; + +describe('ReviewList', () => { + it('viser vurderingene i rekkefølgen den får, med score, dato og kommentar', () => { + render(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + + const second = within(items[1]!); + expect(second.getByText('4 av 5')).toBeInTheDocument(); + expect(second.getByText('14. september 2026')).toHaveAttribute( + 'dateTime', + '2026-09-14T09:05:00.000Z', + ); + expect( + second.getByText('Gjorde jobben med hyllene. Kunne hatt flere bits.'), + ).toBeInTheDocument(); + }); + + it('sier fra når en vurdering ikke har kommentar', () => { + render(); + expect( + within(screen.getAllByRole('listitem')[0]!).getByText('Ingen kommentar.'), + ).toBeInTheDocument(); + }); + + it('forteller hvor mange som vises når det finnes flere', () => { + render(); + expect( + screen.getByText('Viser de 3 nyeste av 12 vurderinger.'), + ).toBeInTheDocument(); + }); + + it('viser en melding når annonsen ikke har vurderinger', () => { + render(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect( + screen.getByText( + 'Ingen har vurdert dette verktøyet ennå. Bli den første!', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/ReviewList/ReviewList.tsx b/src/components/ReviewList/ReviewList.tsx new file mode 100644 index 0000000..fed6346 --- /dev/null +++ b/src/components/ReviewList/ReviewList.tsx @@ -0,0 +1,65 @@ +import { REVIEW_SCORE_MAX } from '../../graphql/reviewMutations.ts'; +import type { Review } from '../../types/review.ts'; +import { + formatReviewDate, + reviewCountLabel, + scoreLabel, +} from '../../utils/reviewFormat.ts'; +import styles from './ReviewList.module.css'; + +interface ReviewListProps { + /** De nyeste vurderingene, nyeste først. */ + reviews: Review[]; + /** Antall vurderinger totalt, som kan være flere enn i listen. */ + reviewCount: number; +} + +/** + * De nyeste vurderingene av en annonse. Hver vurdering viser scoren som tekst + * («4 av 5») ved siden av stjernene, datoen og kommentaren. Har annonsen + * flere vurderinger enn listen viser, står det hvor mange som vises. + */ +function ReviewList({ reviews, reviewCount }: ReviewListProps) { + if (reviews.length === 0) { + return ( +

+ Ingen har vurdert dette verktøyet ennå. Bli den første! +

+ ); + } + + return ( + <> + {reviewCount > reviews.length && ( +

+ Viser de {reviews.length} nyeste av {reviewCountLabel(reviewCount)}. +

+ )} +
    + {reviews.map((review) => ( +
  • +

    + + {scoreLabel(review.score)} + +

    + {review.comment ? ( +

    {review.comment}

    + ) : ( +

    Ingen kommentar.

    + )} +
  • + ))} +
+ + ); +} + +export default ReviewList; diff --git a/src/pages/ListingDetailPage/ListingDetailPage.module.css b/src/pages/ListingDetailPage/ListingDetailPage.module.css index 95a8ff2..7420c5e 100644 --- a/src/pages/ListingDetailPage/ListingDetailPage.module.css +++ b/src/pages/ListingDetailPage/ListingDetailPage.module.css @@ -63,6 +63,24 @@ padding-left: var(--space-6); } +.reviews { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: 40rem; + margin-bottom: var(--space-8); +} + +.reviews h2, +.reviewFormHeading { + margin: 0; +} + +.reviewFormHeading { + margin-top: var(--space-4); + font-size: var(--font-size-lg); +} + .request { max-width: 40rem; padding: var(--space-6); diff --git a/src/pages/ListingDetailPage/ListingDetailPage.tsx b/src/pages/ListingDetailPage/ListingDetailPage.tsx index 5384027..30f7083 100644 --- a/src/pages/ListingDetailPage/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage/ListingDetailPage.tsx @@ -4,10 +4,15 @@ 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 { LISTING_QUERY } from '../../graphql/listingQueries.ts'; +import ReviewForm from '../../components/ReviewForm/ReviewForm.tsx'; +import ReviewList from '../../components/ReviewList/ReviewList.tsx'; +import ReviewScore from '../../components/ReviewScore/ReviewScore.tsx'; +import { + LISTING_QUERY, + type ListingDetail, +} 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 { catalogBackLink } from '../../utils/catalogPage.ts'; import styles from './ListingDetailPage.module.css'; @@ -77,7 +82,7 @@ function ListingDetails({ listing, backTo, }: { - listing: Listing; + listing: ListingDetail; backTo: string; }) { return ( @@ -92,6 +97,10 @@ function ListingDetails({

{listing.title}

+
@@ -124,6 +133,21 @@ function ListingDetails({
+
+

Vurderinger

+ + +

Gi en vurdering

+ {/* key nullstiller skjemaet når brukeren går til en annen annonse. */} + +
+
Date: Thu, 29 Oct 2026 22:12:52 +0100 Subject: [PATCH 11/19] test(src): test vurderinger i katalogen og detaljvisningen --- src/App.test.tsx | 109 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/App.test.tsx b/src/App.test.tsx index f72b6b0..5f8c40d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,9 +3,11 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { mockListings } from './data/mockListings.ts'; import { + createReviewMock, listingMock, listingsPageMock, mockAreas, + mockReviews, networkErrorMock, } from './test/listingMocks.ts'; import { renderApp } from './test/renderApp.tsx'; @@ -961,3 +963,110 @@ describe('detaljvisningen', () => { ).toBeInTheDocument(); }); }); + +describe('vurderinger', () => { + const drill = mockListings.find((listing) => listing.id === 'listing-001')!; + const newReview = { + id: 'review-ny', + score: 3, + comment: 'Grei nok, men tung.', + createdAt: '2026-10-20T12:00:00.000Z', + }; + // Snittet etter den nye vurderingen: (5 + 4 + 5 + 3) / 4. + const newAverage = 17 / 4; + + function reviewsSection() { + return screen.getByRole('region', { name: 'Vurderinger' }); + } + + it('viser snittscore med tekst på annonsekortene', async () => { + renderApp(); + await findCards(); + const drillCard = screen + .getByRole('link', { name: 'Drill' }) + .closest('article')!; + expect(drillCard).toHaveTextContent('4,7 av 5 (3 vurderinger)'); + + const toolboxCard = screen + .getByRole('link', { name: /Verktøykasse/ }) + .closest('article')!; + expect(toolboxCard).toHaveTextContent('Ingen vurderinger ennå'); + }); + + it('viser snitt, antall og de nyeste vurderingene i detaljvisningen', async () => { + renderApp('/annonser/listing-001', [ + listingMock('listing-001', drill, mockReviews), + ]); + await screen.findByRole('heading', { level: 1, name: 'Drill' }); + + const section = reviewsSection(); + expect(section).toHaveTextContent('4,7 av 5 (3 vurderinger)'); + const items = within(section).getAllByRole('listitem'); + expect(items).toHaveLength(mockReviews.length); + expect(items[1]).toHaveTextContent(mockReviews[1]!.comment); + expect( + within(section).getByRole('group', { name: 'Vurdering' }), + ).toBeInTheDocument(); + }); + + it('viser den nye vurderingen med en gang, uten å hente annonsen på nytt', async () => { + // Bare én mock for detaljspørringen: en ny henting ville gitt feil. + renderApp('/annonser/listing-001', [ + listingMock('listing-001', drill, mockReviews), + createReviewMock( + { listingId: 'listing-001', score: 3, comment: newReview.comment }, + { reviews: [newReview, ...mockReviews], averageScore: newAverage }, + ), + ]); + await screen.findByRole('heading', { level: 1, name: 'Drill' }); + + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.type( + screen.getByRole('textbox', { name: 'Kommentar (valgfri)' }), + newReview.comment, + ); + await userEvent.click( + screen.getByRole('button', { name: 'Legg ut vurdering' }), + ); + + expect(await screen.findByText(/Takk!/)).toBeInTheDocument(); + const section = reviewsSection(); + const items = within(section).getAllByRole('listitem'); + expect(items).toHaveLength(4); + expect(items[0]).toHaveTextContent(newReview.comment); + expect(section).toHaveTextContent('4,3 av 5 (4 vurderinger)'); + // Snittet ved prisen øverst oppdateres også. + expect( + screen.getAllByText('4,3 av 5 (4 vurderinger)').length, + ).toBeGreaterThanOrEqual(2); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('oppdaterer snittet på kortet i katalogen fra cachen', async () => { + renderApp('/', [ + listingsPageMock(), + listingMock('listing-001', drill, mockReviews), + createReviewMock( + { listingId: 'listing-001', score: 3 }, + { reviews: [newReview, ...mockReviews], averageScore: newAverage }, + ), + ]); + await userEvent.click(await screen.findByRole('link', { name: 'Drill' })); + await screen.findByRole('heading', { level: 1, name: 'Drill' }); + + await userEvent.click(screen.getByRole('radio', { name: '3 – Grei' })); + await userEvent.click( + screen.getByRole('button', { name: 'Legg ut vurdering' }), + ); + await screen.findByText(/Takk!/); + + // Bare én mock for katalogen: kortet må oppdateres fra cachen. + await userEvent.click( + screen.getByRole('link', { name: 'Tilbake til katalogen' }), + ); + const drillCard = ( + await screen.findByRole('link', { name: 'Drill' }) + ).closest('article')!; + expect(drillCard).toHaveTextContent('4,3 av 5 (4 vurderinger)'); + }); +}); From 35bca3f1894572d366c0cc302fd2144ec0a899bd Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:15:56 +0100 Subject: [PATCH 12/19] docs(docs): dokumenter vurderinger av annonser --- README.md | 69 ++++++++++---- docs/design.md | 42 ++++++++- docs/testing.md | 69 +++++++------- docs/vurderinger.md | 218 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 53 deletions(-) create mode 100644 docs/vurderinger.md diff --git a/README.md b/README.md index 2ca61db..11689a5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ hvordan løsningen er tenkt brukt. Leieforespørselen er fortsatt demo. Søk, filtre og sortering i katalogen gjøres på serveren, i hele databasen, se -[Søk, filtre og sortering](#søk-filtre-og-sortering). Se også +[Søk, filtre og sortering](#søk-filtre-og-sortering). Brukere kan også gi +annonser en vurdering fra 1 til 5, se [Vurderinger](#vurderinger). Se også [Frontend mot GraphQL](#frontend-mot-graphql) og [Backend og database](#backend-og-database). @@ -48,11 +49,11 @@ låne verktøy i nærheten i stedet for å kjøpe nytt. ### Visninger og navigasjon -| Visning | Slik kommer du dit | Hva du kan gjøre | -| -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Søke, filtrere på kategori og gratis utlån, sortere og bla, 24 om gangen | -| 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 | +| Visning | Slik kommer du dit | Hva du kan gjøre | +| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Søke, filtrere på kategori og gratis utlån, sortere og bla, 24 om gangen | +| Detaljvisning | Klikk på et annonsekort i katalogen | Se bilde, beskrivelse, tilstand, tilbehør, pris og vurderinger, gi en vurdering, 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 | Menyen ligger øverst på alle sider. Detaljvisningen har en lenke tilbake til katalogen, og en ukjent adresse gir en side med lenke til katalogen. Alt kan @@ -72,8 +73,11 @@ annonser viser for eksempel «150 kr/døgn», mens gratis annonser viser Illustrasjonene er laget for prosjektet. - **Leieforespørselen er en demo.** Knappen heter «Send forespørsel (demo)». Ingen forespørsel blir sendt, og verktøyet blir ikke reservert. -- **Ingen innlogging.** Alle kan legge ut annonser, og annonsene har ingen - eier ennå. +- **Eksempelvurderingene er oppdiktede.** `npm run seed` legger inn 9 + vurderinger av noen av de håndskrevne annonsene, fra + `backend/src/seed/reviews.ts`. +- **Ingen innlogging.** Alle kan legge ut annonser og gi vurderinger, og + annonsene og vurderingene har ingen eier ennå. `src/data/mockListings.ts` brukes nå bare i tester og på designsiden. @@ -91,9 +95,12 @@ I den ferdige løsningen ligger dataene i en database: | Annonse | Ett fysisk eksemplar med eier, tilstand, pris, område og bilde | | Bruker | Den som legger ut annonser eller sender leieforespørsler | | Booking | Leieperiode og status: sendt, godkjent, avslått, utlevert eller levert | -| Vurdering | Leietakerens vurdering etter et gjennomført leieforhold | +| Vurdering | Score fra 1 til 5 og en valgfri kommentar til en annonse | -Annonser, bookinger og vurderinger er de brukergenererte dataene som lagres. +Annonser og vurderinger er de brukergenererte dataene som lagres i dag. +Bookinger kommer senere. Da bør en vurdering høre til et gjennomført +leieforhold, men nå kan hvem som helst gi en vurdering, se +[docs/vurderinger.md](docs/vurderinger.md). Eksempelannonsene seedes i PostgreSQL via `backend/` (samme innhold som mock). For å teste skalering fyller `npm run seed:large` databasen med 10 000 genererte annonser. Verktøytypene og merkene er ekte og hentet fra Wikidata, @@ -196,6 +203,7 @@ Frontend bruker [Apollo Client](https://www.apollographql.com/docs/react/) for | `src/graphql/client.ts` | Apollo-klienten og adressen til API-et | | `src/graphql/listingQueries.ts` | Spørringene for katalogen og detaljvisningen | | `src/graphql/listingMutations.ts` | Mutasjonen som lagrer nye annonser | +| `src/graphql/reviewMutations.ts` | Mutasjonen som lagrer nye vurderinger | | `src/test/listingMocks.ts` | Mockede GraphQL-svar til testene | Katalogen henter bare feltene annonsekortet viser, og 24 annonser per side. @@ -207,6 +215,12 @@ klientvalideringen er godkjent. Serveren validerer den på nytt. Etter lagring fjernes de hentede katalogsidene fra cachen, så katalogen hentes på nytt neste gang den vises, med den nye annonsen øverst. +**Nye vurderinger:** `createReview` svarer med annonsen med nytt snitt, antall +og liste over vurderinger, i samme form som detaljspørringen. Apollo oppdaterer +annonsen i cachen, så detaljvisningen og annonsekortet viser den nye +vurderingen uten en ny spørring, se +[docs/vurderinger.md](docs/vurderinger.md#ny-vurdering-uten-ny-henting). + **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 @@ -308,7 +322,7 @@ psql -h localhost -U verktoyutlan_dev -d verktoyutlan -W -c "SELECT COUNT(*) FRO npm run seed # forvent 0 nye annonser andre gang ``` -Forvent **10** rader i `listings`. Seed-data tilsvarer +Forvent **10** rader i `listings` og **9** i `reviews`. Seed-data tilsvarer `src/data/mockListings.ts` (id `listing-001`–`listing-010`). **7. Stort datasett:** @@ -347,12 +361,12 @@ Serveren logger URL til GraphQL-endepunktet (standard **Queries** -| Felt | Beskrivelse | -| ------------------ | --------------------------------------------------------------- | -| `health` | Returnerer `"ok"`. | -| `listings(…)` | Én side med annonser. Se paginering, søk og filtre under. | -| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. | -| `listingAreas` | Områdene som har minst én annonse, sortert alfabetisk på norsk. | +| Felt | Beskrivelse | +| ------------------ | ------------------------------------------------------------------------------------------ | +| `health` | Returnerer `"ok"`. | +| `listings(…)` | Én side med annonser. Se paginering, søk og filtre under. | +| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. Har snitt, antall og de nyeste vurderingene. | +| `listingAreas` | Områdene som har minst én annonse, sortert alfabetisk på norsk. | **Paginering:** `listings` returnerer en `ListingPage` med `items`, `totalCount` (antall annonser totalt) og `hasNextPage`. `limit` er @@ -398,9 +412,10 @@ trigram-indeks (`pg_trgm`) for søk i tekst. Med indeksene tar et søk under 2 m **Mutations** -| Felt | Beskrivelse | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | +| Felt | Beskrivelse | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | +| `createReview(input: CreateReviewInput!)` | Lagrer en vurdering fra 1 til 5 med valgfri kommentar, og gir annonsen med oppdatert snitt og liste. Se [docs/vurderinger.md](docs/vurderinger.md#graphql). | Feltene returneres i camelCase (`dailyPrice`, `imageUrl`, osv.). Enkelt eksempel med curl (juster URL om du har endret `PORT`): @@ -542,6 +557,19 @@ og valget er begrunnet i dokumentasjonen. Hvordan det virker, valideringen, tilgjengeligheten og filene står i [docs/sok-og-filtre.md](docs/sok-og-filtre.md). +## Vurderinger + +Brukere kan gi en annonse en vurdering fra 1 til 5 med en valgfri kommentar. +Annonsekortet viser snittet som stjerner og tekst, for eksempel «4,7 av 5 (3 +vurderinger)». Detaljvisningen viser snittet, de 10 nyeste vurderingene og et +skjema for å gi en ny. Den nye vurderingen og det nye snittet vises med en +gang, uten at siden lastes på nytt. + +Vurderingene lagres i tabellen `reviews`, og snitt og antall regnes ut i +samme spørring som annonsene, uten én ekstra spørring per kort. Datamodellen, +API-et, valideringen, tilgjengeligheten og testene står i +[docs/vurderinger.md](docs/vurderinger.md). + ## Design Felles farger, typografi, avstander og komponenter er beskrevet i @@ -591,6 +619,7 @@ dataene i appen kommer fra. | [docs/prosjektstandard.md](docs/prosjektstandard.md) | Branches, commit-format, pull requests og krav før merge | | [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter | | [docs/sok-og-filtre.md](docs/sok-og-filtre.md) | Søk, filtre og sortering: oppførsel, validering og filer | +| [docs/vurderinger.md](docs/vurderinger.md) | Vurderinger: datamodell, API, validering og cache | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | | [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | | [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | diff --git a/docs/design.md b/docs/design.md index a30b316..6e07ce4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -103,8 +103,8 @@ bakgrunn, men teksten alene er nok til å skille dem. ### ListingCard -Annonsekort med bilde, tittel, kategori, område og pris. Bildet vises med -`ListingImage`. +Annonsekort med bilde, tittel, kategori, område, snittscore og pris. Bildet +vises med `ListingImage`, og snittscoren med `ReviewScore`. ```tsx @@ -151,6 +151,44 @@ 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. +### ReviewScore + +Snittscore som stjerner og tekst, på annonsekortet og i detaljvisningen. + +```tsx + +// ★★★★★ 4,7 av 5 (3 vurderinger) +``` + +De fylte stjernene ligger over fem tomme og klippes til snittet, så 4,7 gir +nesten fem fylte. Stjernene har `aria-hidden="true"`, og teksten står alltid +ved siden av, så scoren ikke bare vises med symboler eller farge. Uten +vurderinger vises bare «Ingen vurderinger ennå» i dempet tekst. Fylte +stjerner har aksentfargen, tomme har `--color-border-strong`. + +### ReviewList + +De nyeste vurderingene av en annonse, som en liste med kort. Hvert kort viser +stjerner, scoren som tekst («4 av 5»), datoen i en `
-
diff --git a/src/components/ReviewForm/ReviewForm.test.tsx b/src/components/ReviewForm/ReviewForm.test.tsx index 85e852a..e8a9f04 100644 --- a/src/components/ReviewForm/ReviewForm.test.tsx +++ b/src/components/ReviewForm/ReviewForm.test.tsx @@ -95,7 +95,10 @@ describe('ReviewForm', () => { await userEvent.type(commentField(), ' Fin drill. '); await userEvent.click(submitButton()); - expect(submitButton()).toBeDisabled(); + expect(submitButton()).toHaveAttribute('aria-disabled', 'true'); + // Ikke disabled: da mister knappen fokus i nettleseren (ikke i jsdom), og + // tastaturbrukere havner øverst på siden. + expect(submitButton()).not.toBeDisabled(); expect(submitButton()).toHaveTextContent('Lagrer'); expect( @@ -106,7 +109,24 @@ describe('ReviewForm', () => { expect(screen.getByRole('status')).toHaveTextContent('Takk!'); expect(screen.getByRole('radio', { name: '4 – Bra' })).not.toBeChecked(); expect(commentField()).toHaveValue(''); - expect(submitButton()).toBeEnabled(); + expect(submitButton()).not.toHaveAttribute('aria-disabled'); + }); + + it('beholder fokus på knappen og sender bare én gang ved Enter to ganger', async () => { + // Bare én mock: en ny innsending ville gitt feilmelding. + renderForm([ + createReviewMock( + { listingId: LISTING_ID, score: 4 }, + { reviews: mockReviews, averageScore: 14 / 3 }, + ), + ]); + await userEvent.click(screen.getByRole('radio', { name: '4 – Bra' })); + submitButton().focus(); + await userEvent.keyboard('{Enter}{Enter}'); + + expect(await screen.findByText(/Takk!/)).toBeInTheDocument(); + expect(submitButton()).toHaveFocus(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('sender ikke med en tom kommentar', async () => { diff --git a/src/components/ReviewForm/ReviewForm.tsx b/src/components/ReviewForm/ReviewForm.tsx index e502aca..45d4b1f 100644 --- a/src/components/ReviewForm/ReviewForm.tsx +++ b/src/components/ReviewForm/ReviewForm.tsx @@ -142,7 +142,11 @@ function ReviewForm({ listingId }: ReviewFormProps) { />
-
From a63897f65877f760e9e509ed056794f2e7090018 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:29:45 +0100 Subject: [PATCH 18/19] =?UTF-8?q?fix(pages):=20flytt=20fokus=20n=C3=A5r=20?= =?UTF-8?q?=C2=ABFjern=20alle=20filtre=C2=BB=20eller=20=C2=ABVis=20alle=20?= =?UTF-8?q?annonser=C2=BB=20forsvinner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: andretg --- src/App.test.tsx | 27 +++++++++++++++++++++++++++ src/pages/CatalogPage/CatalogPage.tsx | 26 +++++++++++++++++--------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index b129fcc..2ed9fca 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1107,6 +1107,33 @@ describe('fokus når katalogen endres', () => { expect(searchbox).toHaveFocus(); }); + it('flytter fokus til filterpanelet etter «Fjern alle filtre»', async () => { + renderApp('/?q=drill&kategori=garden', [ + listingsPageMock({ search: 'drill', category: 'garden' }), + listingsPageMock(), + ]); + await findCards(); + + await userEvent.click( + screen.getByRole('button', { name: 'Fjern alle filtre' }), + ); + expect( + screen.getByRole('group', { name: 'Filtrer og sorter' }), + ).toHaveFocus(); + }); + + it('flytter fokus til treffene etter «Vis alle annonser»', async () => { + renderApp('/?q=xyz', [ + listingsPageMock({ search: 'xyz', items: [], totalCount: 0 }), + listingsPageMock(), + ]); + await userEvent.click( + await screen.findByRole('button', { name: 'Vis alle annonser' }), + ); + expect(results()).toHaveFocus(); + expect(await findCards()).toHaveLength(mockListings.length); + }); + it('lar fokus stå i filteret når et filter starter på side 1 igjen', async () => { renderApp('/?side=2', [ listingsPageMock({ page: 2, totalCount: 30 }), diff --git a/src/pages/CatalogPage/CatalogPage.tsx b/src/pages/CatalogPage/CatalogPage.tsx index 74226e0..45c1829 100644 --- a/src/pages/CatalogPage/CatalogPage.tsx +++ b/src/pages/CatalogPage/CatalogPage.tsx @@ -77,17 +77,26 @@ function CatalogPage() { [updateFilters], ); - // Fjerner søk og filtre, men beholder sorteringen. - const clearFilters = useCallback( - () => updateFilters(NO_LISTING_FILTERS), - [updateFilters], - ); - // Når den siste filterbrikken er fjernet, finnes ikke knappen som hadde // fokus lenger. Fokus går da til filterpanelet, rett over der brikkene stod. const filtersRef = useRef(null); const focusFilters = useCallback(() => filtersRef.current?.focus(), []); + // «Fjern alle filtre» og «Vis alle annonser» fjerner søk og filtre, men + // beholder sorteringen. Begge knappene forsvinner når de er trykket, så + // fokus flyttes, ellers havner det øverst på siden. «Fjern alle filtre» + // går til filterpanelet, der brukeren kan velge nye filtre. «Vis alle + // annonser» går til treffene, som brukeren ba om å se. + const resultsRef = useRef(null); + const clearFilters = useCallback(() => { + updateFilters(NO_LISTING_FILTERS); + focusFilters(); + }, [updateFilters, focusFilters]); + const showAllListings = useCallback(() => { + updateFilters(NO_LISTING_FILTERS); + resultsRef.current?.focus({ preventScroll: true }); + }, [updateFilters]); + // Husker søket, slik at «Katalog» i menyen kan gå tilbake til det. const urlSearch = searchParams.toString(); useEffect(() => { @@ -101,7 +110,6 @@ function CatalogPage() { // på lenken nederst, mens siden viser toppen, og neste Tab ville gått ut av // siden. Endres søk eller filtre, blir fokus stående i feltet brukeren // bruker. - const resultsRef = useRef(null); const queryWithoutPage = withoutCatalogPage(searchParams).toString(); const previousPage = useRef(page); const previousQuery = useRef(queryWithoutPage); @@ -144,7 +152,7 @@ function CatalogPage() { onEmpty={focusFilters} /> - {/* Får fokus når brukeren blar, se effekten over. */} + {/* Får fokus når brukeren blar eller trykker «Vis alle annonser». */}
void refetch()} - onClearFilters={clearFilters} + onClearFilters={showAllListings} />
From e055c4552e8c0e884f4abe94db0dc8b5bcd80075 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:31:52 +0100 Subject: [PATCH 19/19] docs(docs): dokumenter gjennomgangen av tilgjengelighet Co-Authored-By: andretg --- README.md | 8 +- docs/design.md | 30 +++++--- docs/testing.md | 83 ++++++++++---------- docs/tilgjengelighet.md | 167 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 54 deletions(-) create mode 100644 docs/tilgjengelighet.md diff --git a/README.md b/README.md index 11689a5..339a6b0 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,10 @@ mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md). parter ser sine leieforhold. Godkjente bookinger kan ikke overlappe for samme verktøy. Utlevering og retur registreres, og leietakeren kan gi en vurdering etterpå. Gratis utlån bruker samme flyt. -- **Tilgjengelighet:** Semantisk HTML, tastaturnavigasjon, synlig fokus og - kontrast etter WCAG 2.1 er på plass allerede. Senere kommer test med - skjermleser og en manuell brukertest. +- **Tilgjengelighet:** Appen er gått gjennom mot WCAG 2.1 AA med axe-core, + tastatur, 320 px bredde og `prefers-reduced-motion`, og funnene er rettet, + se [docs/tilgjengelighet.md](docs/tilgjengelighet.md). Test med skjermleser + gjenstår. - **Bærekraft:** Små bilder som lastes når de trengs, systemfont, buffring og spørringer som bare henter feltene som vises. Vi måler datamengde og antall forespørsler. @@ -620,6 +621,7 @@ dataene i appen kommer fra. | [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter | | [docs/sok-og-filtre.md](docs/sok-og-filtre.md) | Søk, filtre og sortering: oppførsel, validering og filer | | [docs/vurderinger.md](docs/vurderinger.md) | Vurderinger: datamodell, API, validering og cache | +| [docs/tilgjengelighet.md](docs/tilgjengelighet.md) | Gjennomgang mot WCAG 2.1 AA: metode, funn og rettelser | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | | [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | | [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | diff --git a/docs/design.md b/docs/design.md index 6e07ce4..51b78ab 100644 --- a/docs/design.md +++ b/docs/design.md @@ -41,22 +41,28 @@ fokusmarkering. Kontrollerte fargepar: -| Forgrunn | Bakgrunn | Kontrast | Krav | -| ----------------------- | ----------------------- | -------- | ----- | -| `--color-text` | `--color-bg` | 14,9:1 | 4,5:1 | -| `--color-text-muted` | `--color-bg` | 5,9:1 | 4,5:1 | -| `--color-text-muted` | `--color-surface-muted` | 5,4:1 | 4,5:1 | -| `--color-on-accent` | `--color-accent` | 6,3:1 | 4,5:1 | -| `--color-on-accent` | `--color-accent-hover` | 8,7:1 | 4,5:1 | -| `--color-accent` | `--color-bg` | 5,9:1 | 4,5:1 | -| `--color-free-text` | `--color-free-bg` | 7,7:1 | 4,5:1 | -| `--color-text` | `--color-surface-muted` | 13,5:1 | 4,5:1 | -| `--color-error` | `--color-surface` | 6,5:1 | 4,5:1 | -| `--color-border-strong` | `--color-surface` | 4,3:1 | 3:1 | +| Forgrunn | Bakgrunn | Kontrast | Krav | +| ---------------------------- | ----------------------- | -------- | ----- | +| `--color-text` | `--color-bg` | 14,9:1 | 4,5:1 | +| `--color-text-muted` | `--color-bg` | 5,9:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface-muted` | 5,4:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent` | 6,3:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent-hover` | 8,7:1 | 4,5:1 | +| `--color-accent` | `--color-bg` | 5,9:1 | 4,5:1 | +| `--color-free-text` | `--color-free-bg` | 7,7:1 | 4,5:1 | +| `--color-text` | `--color-surface-muted` | 13,5:1 | 4,5:1 | +| `--color-error` | `--color-surface` | 6,5:1 | 4,5:1 | +| `--color-border-strong` | `--color-surface` | 4,3:1 | 3:1 | +| `--color-text-muted` | `--color-surface` | 6,3:1 | 4,5:1 | +| Fokusring (`--color-accent`) | `--color-bg` | 5,9:1 | 3:1 | `--color-border` har lav kontrast (1,3:1) og brukes bare til dekorative rammer, der innholdet er forståelig uten rammen. +Plassholdertekst i felt bruker `--color-text-muted` (6,3:1 mot hvitt). Uten +den regelen gir Firefox bare 3,5:1, se +[tilgjengelighet.md](tilgjengelighet.md#funn-og-rettelser). + ## Typografi | Variabel | Størrelse | Bruk | diff --git a/docs/testing.md b/docs/testing.md index d94fec4..25e2034 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -67,44 +67,45 @@ testdatabase-URL kjører fortsatt. ## Hva som testes -| 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. Søk mot mockede svar: søk fra URL-en, bare det ferdige søket sendes, nytt søk starter på side 1 og blaing beholder søket, forrige resultat vises under lasting, sene svar på eldre søk vises ikke, søk uten treff. Filtre og sortering: valgene leses fra URL-en og sendes til serveren, ugyldige verdier ignoreres, endrede valg starter på side 1, blaing beholder alle valgene, «Fjern alle filtre» beholder sorteringen, tomt resultat med filtre. Område, tilstand og pris: valgene fra URL-en sendes til serveren, områdene hentes fra serveren, hvert filter gir ny forespørsel fra side 1, ugyldig prisintervall gir feil og verken forespørsel eller endret URL, ugyldig intervall i URL-en ignoreres, blaing og «Fjern alle filtre», katalogen virker når områdene ikke kan hentes. Aktive filtre og tomt resultat: brikker fra URL-en, ingen brikker uten filtre, fjerning av ett filter med fokus på neste brikke, fokus til filterpanelet når siste brikke er fjernet, ulike forslag for bare søk og søk med filtre, tom katalog skilt fra resultat uten treff. Tilbake fra en annonse: lenken tar med søk, filtre, sortering og side, «Tilbake til katalogen» og nettleserens tilbakeknapp viser resultatet fra cachen uten ny forespørsel, direkte åpnet annonse og «Fant ikke annonsen» lenker til katalogen uten filtre. «Katalog» i menyen: går tilbake til siste søk etter «Lei ut verktøy» fra cachen, følger med når filtrene endres, husker fjernede filtre, navnet øverst går til katalogen uten filtre. Vurderinger: snitt på kortene, vurderinger i detaljvisningen, ny vurdering vises med en gang og kortet i katalogen oppdateres fra cachen | -| `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 | -| `components/PriceRangeFilter/` | Etiketter og `inputmode`, debouncing, lik fra- og til-pris, feil ved «Til» når «Fra» er høyere, feil for «-5», «12,5» og «ti», ingenting sendes ved feil, tomt felt fjerner grensen, endring utenfra fjerner feil | -| `components/CatalogSearch/` | Etikett og søkeområde, debouncing med ny ventetid ved hvert tastetrykk, Enter og «Søk» søker med en gang, mellomrom i endene, «Tøm søket» med fokus i feltet, søk som endres utenfra | -| `components/CatalogFilters/` | Etiketter og navngitt gruppe, viser valgene den får, melder fra om kategori, «Alle kategorier», gratis utlån, sortering, område og tilstand, «Fjern alle filtre» bare når noe er valgt, områdeboksen starter lukket og viser valgene i overskriften, lasting og ingen områder, valgte områder kan fjernes mens listen lastes | -| `components/ActiveFilters/` | Vises ikke uten filtre, navngitt liste, fjerner riktig valg, tastatur, fokus til neste eller forrige brikke, `onEmpty` når den siste er fjernet | -| `utils/activeFilters.test.ts` | Én brikke per valg i fast rekkefølge, unike nøkler, navn som inneholder den synlige teksten, fjerning av bare ett valg, tekst for prisintervall med tusenskille, forslag når ingenting passer | -| `components/CheckboxGroup/` | Navngitt `fieldset`, avkrysning legger til og fjerner i fast rekkefølge, mellomromstasten, skjult legend beholder navnet | -| `utils/catalogMemory.test.ts` | Søket lagres og leses i `sessionStorage`, tomt søk huskes, endrede verdier brukes ikke, appen virker når lagringen er blokkert | -| `utils/catalogPage.test.ts` | Lesing av `?side=` fra URL-en, lenker til sider som beholder søk og filtre, fjerning av siden, antall sider, og lenken tilbake fra en annonse med gyldig og ugyldig `state` | -| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | -| `utils/listingFilters.test.ts` | Filtre i URL-en: gyldige og ugyldige verdier, for langt søk kortes ned, gjentatte parametere for område og tilstand, ugyldige priser og makspris under minstepris ignoreres, tomme filtre fjernes, andre parametere beholdes, om noe søk eller filter er valgt, og teksten for valgte områder | -| `utils/listingSort.test.ts` | Sortering i URL-en: nyeste som standard, ukjente verdier gir standard, standard fjernes fra 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», skjemaverdier gjort om til input for `createListing` | -| `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. Innsending med mockede GraphQL-svar: bekreftelse med lenke, tømt skjema, deaktivert knapp under lagring, feil fra serveren og nettverksfeil der feltene beholdes | -| `components/ReviewForm/` | Etiketter, piltaster, feil og fokus, for lang kommentar, lagring og tømt skjema, tom kommentar sendes ikke, feil fra serveren og nettverksfeil | -| `components/ReviewList/` | Rekkefølge, score, dato og kommentar, «Ingen kommentar», hvor mange som vises, ingen vurderinger | -| `components/ReviewScore/` | Snittet som tekst ved stjernene, stjernene leses ikke opp, «Ingen vurderinger ennå» | -| `utils/reviewForm.test.ts` | Validering av score og kommentar, input til `createReview` | -| `utils/reviewFormat.test.ts` | Snitt med én desimal, bøyning av antall, dato i norsk tid | -| `graphql/listingMutations.test.ts` | Feilmelding ved `BAD_USER_INPUT`, andre feil gir ingen melding, katalogsider fjernes fra cachen etter lagring | -| `graphql/listingQueries.test.ts` | Variablene til katalogspørringen: posisjon fra siden, søk uten mellomrom i endene, filtre og sortering, område, tilstand og prisintervall, standardvalg og tomme lister utelates | -| `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/graphql/listingsFilter.api.test.ts` | Søk, filtre og sortering i `listings`: store og små bokstaver også for æ, ø og å, søk i beskrivelsen, flere ord, tomt søk, `%` og `_` som vanlig tekst, hvert filter og kombinasjoner, ett og flere områder, områdenavn må være helt like, tilstander, prisintervall med grensene inkludert, tomme lister og `null`, `listingAreas` med nye områder sortert på norsk, hver sortering med stabil rekkefølge ved lik pris, `totalCount` og sider med filtre, søk uten treff og ugyldige verdier, også ukjent tilstand, negativ pris og `minPrice` over `maxPrice` | -| `backend/src/db/listingsQuery.test.ts` | WHERE og ORDER BY bygges med parametere, escaping av `%`, `_` og `\`, nummerering av plassholdere, `=` for ett område og `ANY` for flere, tilstand og pris, alle filtrene kombinert, norsk sortering av områder, uten database | -| `backend/src/validation/listingFilterArgs.test.ts` | Standardverdier, oppdeling av søket i ord, maksimal lengde på søket, områder uten mellomrom, tomme navn og duplikater, grenser for antall områder og lengde, duplikate tilstander, prisintervall og ugyldige priser, uten database | -| `backend/src/graphql/reviews.api.test.ts` | Vurderinger: lagring, snitt og antall, nyeste først, `limit`, snitt i katalogen, ugyldig score, kommentar og annonse, `CHECK` og `ON DELETE CASCADE` i databasen | -| `backend/src/validation/createReviewInput.test.ts` | Grenser for score, kommentar og `limit`, uten database | -| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | +| 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. Søk mot mockede svar: søk fra URL-en, bare det ferdige søket sendes, nytt søk starter på side 1 og blaing beholder søket, forrige resultat vises under lasting, sene svar på eldre søk vises ikke, søk uten treff. Filtre og sortering: valgene leses fra URL-en og sendes til serveren, ugyldige verdier ignoreres, endrede valg starter på side 1, blaing beholder alle valgene, «Fjern alle filtre» beholder sorteringen, tomt resultat med filtre. Område, tilstand og pris: valgene fra URL-en sendes til serveren, områdene hentes fra serveren, hvert filter gir ny forespørsel fra side 1, ugyldig prisintervall gir feil og verken forespørsel eller endret URL, ugyldig intervall i URL-en ignoreres, blaing og «Fjern alle filtre», katalogen virker når områdene ikke kan hentes. Aktive filtre og tomt resultat: brikker fra URL-en, ingen brikker uten filtre, fjerning av ett filter med fokus på neste brikke, fokus til filterpanelet når siste brikke er fjernet, ulike forslag for bare søk og søk med filtre, tom katalog skilt fra resultat uten treff. Tilbake fra en annonse: lenken tar med søk, filtre, sortering og side, «Tilbake til katalogen» og nettleserens tilbakeknapp viser resultatet fra cachen uten ny forespørsel, direkte åpnet annonse og «Fant ikke annonsen» lenker til katalogen uten filtre. «Katalog» i menyen: går tilbake til siste søk etter «Lei ut verktøy» fra cachen, følger med når filtrene endres, husker fjernede filtre, navnet øverst går til katalogen uten filtre. Vurderinger: snitt på kortene, vurderinger i detaljvisningen, ny vurdering vises med en gang og kortet i katalogen oppdateres fra cachen. Fokus: blaing flytter fokus til treffene, endret søk eller filter lar fokus stå, «Fjern alle filtre» går til filterpanelet og «Vis alle annonser» til treffene | +| `src/accessibility.test.tsx` | axe-core på alle sidene: katalogen med og uten filtre, uten treff, detaljvisningen med vurderinger og med feil i skjemaene, utleieskjemaet tomt og med feil, og sidene som ikke finnes. Se [tilgjengelighet.md](tilgjengelighet.md) | +| `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 | +| `components/PriceRangeFilter/` | Etiketter og `inputmode`, debouncing, lik fra- og til-pris, feil ved «Til» når «Fra» er høyere, feil for «-5», «12,5» og «ti», ingenting sendes ved feil, tomt felt fjerner grensen, endring utenfra fjerner feil | +| `components/CatalogSearch/` | Etikett og søkeområde, debouncing med ny ventetid ved hvert tastetrykk, Enter og «Søk» søker med en gang, mellomrom i endene, «Tøm søket» med fokus i feltet, søk som endres utenfra | +| `components/CatalogFilters/` | Etiketter og navngitt gruppe, viser valgene den får, melder fra om kategori, «Alle kategorier», gratis utlån, sortering, område og tilstand, «Fjern alle filtre» bare når noe er valgt, områdeboksen starter lukket og viser valgene i overskriften, lasting og ingen områder, valgte områder kan fjernes mens listen lastes | +| `components/ActiveFilters/` | Vises ikke uten filtre, navngitt liste, fjerner riktig valg, tastatur, fokus til neste eller forrige brikke, `onEmpty` når den siste er fjernet | +| `utils/activeFilters.test.ts` | Én brikke per valg i fast rekkefølge, unike nøkler, navn som inneholder den synlige teksten, fjerning av bare ett valg, tekst for prisintervall med tusenskille, forslag når ingenting passer | +| `components/CheckboxGroup/` | Navngitt `fieldset`, avkrysning legger til og fjerner i fast rekkefølge, mellomromstasten, skjult legend beholder navnet | +| `utils/catalogMemory.test.ts` | Søket lagres og leses i `sessionStorage`, tomt søk huskes, endrede verdier brukes ikke, appen virker når lagringen er blokkert | +| `utils/catalogPage.test.ts` | Lesing av `?side=` fra URL-en, lenker til sider som beholder søk og filtre, fjerning av siden, antall sider, og lenken tilbake fra en annonse med gyldig og ugyldig `state` | +| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | +| `utils/listingFilters.test.ts` | Filtre i URL-en: gyldige og ugyldige verdier, for langt søk kortes ned, gjentatte parametere for område og tilstand, ugyldige priser og makspris under minstepris ignoreres, tomme filtre fjernes, andre parametere beholdes, om noe søk eller filter er valgt, og teksten for valgte områder | +| `utils/listingSort.test.ts` | Sortering i URL-en: nyeste som standard, ukjente verdier gir standard, standard fjernes fra 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», skjemaverdier gjort om til input for `createListing` | +| `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. Innsending med mockede GraphQL-svar: bekreftelse med lenke, tømt skjema, deaktivert knapp under lagring, feil fra serveren og nettverksfeil der feltene beholdes. Knappen har `aria-disabled`, ikke `disabled`, mens det lagres, og Enter to ganger sender bare én gang | +| `components/ReviewForm/` | Etiketter, piltaster, feil og fokus, for lang kommentar, lagring og tømt skjema, tom kommentar sendes ikke, feil fra serveren og nettverksfeil. Knappen har `aria-disabled`, ikke `disabled`, mens det lagres, og Enter to ganger sender bare én gang | +| `components/ReviewList/` | Rekkefølge, score, dato og kommentar, «Ingen kommentar», hvor mange som vises, ingen vurderinger | +| `components/ReviewScore/` | Snittet som tekst ved stjernene, stjernene leses ikke opp, «Ingen vurderinger ennå» | +| `utils/reviewForm.test.ts` | Validering av score og kommentar, input til `createReview` | +| `utils/reviewFormat.test.ts` | Snitt med én desimal, bøyning av antall, dato i norsk tid | +| `graphql/listingMutations.test.ts` | Feilmelding ved `BAD_USER_INPUT`, andre feil gir ingen melding, katalogsider fjernes fra cachen etter lagring | +| `graphql/listingQueries.test.ts` | Variablene til katalogspørringen: posisjon fra siden, søk uten mellomrom i endene, filtre og sortering, område, tilstand og prisintervall, standardvalg og tomme lister utelates | +| `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/graphql/listingsFilter.api.test.ts` | Søk, filtre og sortering i `listings`: store og små bokstaver også for æ, ø og å, søk i beskrivelsen, flere ord, tomt søk, `%` og `_` som vanlig tekst, hvert filter og kombinasjoner, ett og flere områder, områdenavn må være helt like, tilstander, prisintervall med grensene inkludert, tomme lister og `null`, `listingAreas` med nye områder sortert på norsk, hver sortering med stabil rekkefølge ved lik pris, `totalCount` og sider med filtre, søk uten treff og ugyldige verdier, også ukjent tilstand, negativ pris og `minPrice` over `maxPrice` | +| `backend/src/db/listingsQuery.test.ts` | WHERE og ORDER BY bygges med parametere, escaping av `%`, `_` og `\`, nummerering av plassholdere, `=` for ett område og `ANY` for flere, tilstand og pris, alle filtrene kombinert, norsk sortering av områder, uten database | +| `backend/src/validation/listingFilterArgs.test.ts` | Standardverdier, oppdeling av søket i ord, maksimal lengde på søket, områder uten mellomrom, tomme navn og duplikater, grenser for antall områder og lengde, duplikate tilstander, prisintervall og ugyldige priser, uten database | +| `backend/src/graphql/reviews.api.test.ts` | Vurderinger: lagring, snitt og antall, nyeste først, `limit`, snitt i katalogen, ugyldig score, kommentar og annonse, `CHECK` og `ON DELETE CASCADE` i databasen | +| `backend/src/validation/createReviewInput.test.ts` | Grenser for score, kommentar og `limit`, uten database | +| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | Status 29. oktober 2026, frontend: 22 testfiler og 249 tester, alle grønne. @@ -201,7 +202,11 @@ gjort, og flytt resultatet opp i dokumentet. - [ ] Tastaturnavigasjon og synlig fokus kontrollert for hånd i Firefox eller Safari - [ ] Appen prøvd på en ekte telefon -- [ ] Hovedflyten prøvd med skjermleser (VoiceOver eller NVDA) +- [ ] Hovedflyten prøvd med skjermleser (VoiceOver eller NVDA), se sjekklisten + i [tilgjengelighet.md](tilgjengelighet.md#gjenstår) +- [x] Gjennomgang mot WCAG 2.1 AA med axe-core, tastatur, 320 px og + `prefers-reduced-motion` i Chromium (29. oktober 2026), se + [tilgjengelighet.md](tilgjengelighet.md) - [ ] Manuell brukertest med en person utenfor gruppen - [x] API-tester mot testdatabase (GraphQL i `backend/`, oktober 2026) - [ ] Ende-til-ende-test med Playwright for en hel leieflyt, når booking finnes diff --git a/docs/tilgjengelighet.md b/docs/tilgjengelighet.md new file mode 100644 index 0000000..f9b966a --- /dev/null +++ b/docs/tilgjengelighet.md @@ -0,0 +1,167 @@ +# Tilgjengelighet + +Gjennomgang av appen mot WCAG 2.1 nivå AA, gjort 29. oktober 2026 for +innlevering 3. Dokumentet beskriver hvordan vi sjekket, hva vi fant, hva vi +rettet, og hva som gjenstår. + +## Innhold + +- [Sammendrag](#sammendrag) +- [Slik sjekket vi](#slik-sjekket-vi) +- [Resultat med axe](#resultat-med-axe) +- [Funn og rettelser](#funn-og-rettelser) +- [Det som var på plass fra før](#det-som-var-på-plass-fra-før) +- [Kontroller etter krav](#kontroller-etter-krav) +- [Gjenstår](#gjenstår) +- [Slik sjekker du selv](#slik-sjekker-du-selv) + +## Sammendrag + +- **axe-core** fant ingen brudd på WCAG 2.1 A eller AA på noen side eller i + noen tilstand vi sjekket. +- **Fire funn** som axe ikke fanger, er rettet. Tre gjaldt fokus som + forsvant, og ett gjaldt kontrast på plassholdertekst i Firefox. Se + [Funn og rettelser](#funn-og-rettelser). +- **Automatiske tester** med axe-core kjører nå med `npm test`, så nye brudd + stopper før de havner i en pull request. +- **Skjermlesertest** med VoiceOver eller NVDA er ikke gjort ennå. Den må gjøres + av noen i gruppen, se [Gjenstår](#gjenstår). + +## Slik sjekket vi + +| Kontroll | Hvordan | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| axe i nettleser | [axe-core](https://github.com/dequelabs/axe-core) 4.14, samme motor som Lighthouse bruker, i Chromium mot ekte backend med 50 010 annonser. Reglene for WCAG 2.1 A og AA og axe sine anbefalinger (`best-practice`) | +| axe i testene | `src/accessibility.test.tsx` kjører de samme reglene på hver side med Vitest og jsdom. Kontrast kan ikke måles i jsdom og sjekkes bare i nettleseren | +| Tastatur | Hele flyten med bare Tab, Shift+Tab, Enter, mellomrom og piltaster: søk, filtre, filterbrikker, blaing, detaljvisning, vurdering og utleieskjemaet | +| Fokus | Hvor fokus havner etter blaing, sidebytte, innsending og når en knapp forsvinner | +| Bredde og zoom | Alle sidene på 320 px (tilsvarer 400 % zoom, WCAG 1.4.10) og 640 px (200 % zoom), med filterpanelene åpne. Sjekket at ingenting gir vannrett scrolling | +| Tekstavstand | Linjehøyde 1,5, avsnitt 2em, bokstavavstand 0,12em og ordavstand 0,16em (WCAG 1.4.12). Sjekket at ingen tekst klippes | +| Bevegelse | `prefers-reduced-motion: reduce` emulert. Talt opp animasjoner og overganger | +| Kontrast | Regnet ut for fargene axe ikke dekker, som plassholdertekst og fokusring | +| Struktur | Landemerker, overskrifter, statusmeldinger og alternativ tekst, lest ut fra DOM-en | + +## Resultat med axe + +Ingen brudd på noen av sidene eller tilstandene: + +| Side og tilstand | Brudd | +| ------------------------------------------------------------------- | ----- | +| Katalogen | 0 | +| Katalogen med søk, alle filtre, filterbrikker og åpne filterpaneler | 0 | +| Katalogen uten treff | 0 | +| Detaljvisningen med vurderinger | 0 | +| Detaljvisningen med feil i vurderingsskjemaet og leieforespørselen | 0 | +| Lei ut verktøy, tomt og med feil | 0 | +| «Fant ikke annonsen» og «Fant ikke siden» | 0 | + +axe kunne ikke avgjøre kontrasten for stjernene i snittscoren, fordi de bare +er symboler. Stjernene har `aria-hidden="true"`, og scoren står alltid som +tekst ved siden av («4,7 av 5 (3 vurderinger)»). De er pynt, så kravet til +tekstkontrast gjelder ikke dem. + +## Funn og rettelser + +| # | Funn | WCAG | Rettelse | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Etter «Neste» eller «Forrige» scrollet siden til toppen, men fokus ble stående på lenken nederst. Neste Tab gikk ut av siden | 2.4.3 Fokusrekkefølge | Treffene er en `section` med navnet «Treff» som får fokus når bare siden endres. Endres søk eller filtre, blir fokus i feltet brukeren bruker | +| 2 | Plassholdertekst hadde nettleserens standardfarge. I Firefox er det tekstfargen med 54 % opasitet, 3,5:1 mot hvitt | 1.4.3 Kontrast | `::placeholder` bruker `--color-text-muted`, 6,3:1, i alle nettlesere | +| 3 | «Legg ut vurdering» og «Legg ut verktøy» ble `disabled` mens skjemaet ble lagret. En deaktivert knapp mister fokus, så tastatur- og skjermleserbrukere havnet øverst på siden etter innsending | 2.4.3 Fokusrekkefølge | Knappene bruker `aria-disabled` i stedet. De ser deaktiverte ut og leses som utilgjengelige, men beholder fokus. `handleSubmit` stopper en ny innsending i mellomtiden | +| 4 | «Fjern alle filtre» og «Vis alle annonser» forsvinner når de trykkes, og fokus havnet på `` | 2.4.3 Fokusrekkefølge | «Fjern alle filtre» flytter fokus til filterpanelet. «Vis alle annonser» flytter fokus til treffene | + +I tillegg slår en ny regel i `src/styles/base.css` av animasjoner og +overganger med `prefers-reduced-motion: reduce`. Appen har ingen i dag, så +regelen sørger for at det fortsetter slik. + +Hvert funn har tester, se [testing.md](testing.md). Testene feiler hvis +rettelsen fjernes. + +## Det som var på plass fra før + +- **Hopp-lenke:** «Hopp til hovedinnhold» er første tabulatorstopp. Den er + skjult til den får fokus, og vises da øverst med fokusring. +- **Fokus ved sidebytte:** Ved bytte mellom katalog, annonse og utleieskjema + flyttes fokus til `main`, og fanetittelen endres. +- **Språk:** ``. +- **Landemerker:** `header`, `nav` «Hovedmeny», `main` og `footer` på alle + sider. Søket er et søkeområde, filtrene en gruppe «Filtrer og sorter», og + blaingen en `nav` «Sider». +- **Overskrifter:** Én `h1` per side og ingen hopp i nivå. +- **Skjemaer:** Alle felt har synlig etikett. Hjelpetekst og feil er koblet til + feltet med `aria-describedby`, ugyldige felt har `aria-invalid`, og fokus går + til første felt med feil. +- **Statusmeldinger:** Antall treff, bekreftelser og lasting står i + `role="status"`, og feil fra serveren i `role="alert"`. +- **Ikke bare farge:** Gratis utlån står som tekst, og scoren står som tekst ved + stjernene. +- **Bilder:** Illustrasjonene har `alt=""`, fordi tittelen står rett ved. +- **Kontrast:** Fargeparene i [design.md](design.md#farger) oppfyller 4,5:1 + for tekst og 3:1 for rammer og fokusring. +- **Trykkflater:** Knapper, lenker og valg er minst 44 px høye. + +## Kontroller etter krav + +| Krav fra issue #63 | Status | +| ----------------------------------------------------------------- | ------------------------------------------------------------------ | +| Hver side sjekket med axe eller Lighthouse, alvorlige funn rettet | Gjort. Ingen brudd, se [Resultat med axe](#resultat-med-axe) | +| Hopp-lenke til hovedinnholdet, synlig ved fokus | Gjort, fantes fra før og er kontrollert | +| Fokus til `h1` eller hovedinnholdet ved sidebytte | Gjort. Gjelder nå også blaing, se funn 1 | +| Søk, filtre, blaing og skjemaer prøvd med bare tastatur | Gjort i Chromium. Funn 1, 3 og 4 er rettet | +| Søk, filtre, blaing og skjemaer prøvd med VoiceOver eller NVDA | **Gjenstår**, se under | +| 200 % zoom og 320 px uten vannrett scrolling | Gjort. Ingen vannrett scrolling på noen side | +| `prefers-reduced-motion` respekteres | Gjort. Ingen animasjoner, og en regel sørger for at det fortsetter | +| Funn og rettelser i `docs/tilgjengelighet.md`, lenket fra README | Gjort | + +## Gjenstår + +**Skjermlesertest.** Strukturen er kontrollert ut fra DOM-en, men ingen har +lyttet gjennom appen med en skjermleser ennå. Det bør gjøres av noen i gruppen +før innleveringen, med VoiceOver (Cmd+F5 på Mac) i Safari eller NVDA i +Firefox. Skriv resultatet inn i tabellen. + +| Hva | Forventet | Resultat | +| -------------------------------------------------- | ------------------------------------------------------------------------ | -------- | +| Gå gjennom landemerkene (VoiceOver: VO+U, NVDA: D) | Hovedmeny, hovedinnhold, søk, «Filtrer og sorter», «Treff», «Sider» | | +| Søk etter «drill» og vent | Antall treff leses opp, for eksempel «288 treff for drill, side 1 av 12» | | +| Velg en kategori og fjern den med filterbrikken | Nytt antall leses opp. Brikken heter «Fjern filter: Hage» | | +| Trykk «Neste» | «Treff» og ny side leses opp, og neste kort er første annonse | | +| Åpne en annonse | Tittelen på annonsen leses opp | | +| Gi en vurdering uten å velge score | Feilen leses opp ved gruppen «Vurdering» | | +| Gi en gyldig vurdering | «Takk! Vurderingen din er lagt ut» leses opp, og fokus står på knappen | | +| Send utleieskjemaet tomt | Fokus går til «Tittel», og feilen leses opp | | + +**Andre kjente mangler:** + +- **«Prøv igjen»** i feilmeldingen forsvinner når den trykkes, og fokus havner + øverst på siden. Det skjer bare når backenden ikke svarer, så vi har ikke + rettet det i denne runden. +- **Bare Chromium.** Tastatur og bredde er kontrollert i Chromium, ikke i + Firefox eller Safari. Kontrasten på plassholdertekst er regnet ut for + Firefox. +- **Ingen test med brukere** utenfor gruppen, som avtalt i issuet. + +## Slik sjekker du selv + +**Automatisk:** `npm test` kjører `src/accessibility.test.tsx`, som sjekker hver +side med axe-core. Hjelpefunksjonen `expectNoAxeViolations` i +`src/test/axe.ts` kan brukes i andre tester. + +**I nettleseren:** Start appen med `npm run dev`, åpne en side og lim dette inn +i konsollen. Det laster axe-core fra `node_modules` og skriver ut bruddene: + +```js +const s = document.createElement('script'); +s.src = '/project2/node_modules/axe-core/axe.min.js'; +s.onload = async () => { + const r = await axe.run(document, { + runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'], + }); + console.table( + r.violations.map((v) => ({ id: v.id, antall: v.nodes.length })), + ); +}; +document.head.appendChild(s); +``` + +Lighthouse i Chrome DevTools (fanen «Lighthouse», kategorien +«Accessibility») bruker den samme motoren og gir det samme resultatet.