From 99599b7d7606abe4167a08cf1f27ea6b7bdbc7d7 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 29 Oct 2026 22:01:20 +0100 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] =?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/12] =?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/12] =?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/12] 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/12] 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/12] 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 `