diff --git a/README.md b/README.md index 0c27dd5..1a1543f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ hvordan løsningen er tenkt brukt. **Innlevering 2:** PostgreSQL, migrasjon, seed og GraphQL-API ligger i `backend/`. Katalogen og detaljvisningen henter nå annonsene fra backenden med -Apollo Client, én side om gangen. Leieforespørsel og utleieskjema er fortsatt -demo. Se [Frontend mot GraphQL](#frontend-mot-graphql) og +Apollo Client, én side om gangen, og utleieskjemaet lagrer nye annonser i +databasen. Leieforespørselen er fortsatt demo. Se [Frontend mot GraphQL](#frontend-mot-graphql) og [Backend og database](#backend-og-database). ### Idé @@ -48,10 +48,9 @@ annonser viser for eksempel «150 kr/døgn», mens gratis annonser viser prosjektet. Ekstern datakilde, for eksempel for produktdata, er ikke valgt. - **Leieforespørselen er en demo.** Knappen heter «Send forespørsel (demo)». Ingen forespørsel blir sendt, og verktøyet blir ikke reservert. -- **Utleieskjemaet er en demo.** Knappen heter «Legg ut verktøy (demo)». - Annonsen blir ikke lagret og vises ikke i katalogen. -- **Appen lagrer ikke leie eller nye annonser ennå.** Utleieskjema og - leieforespørsel er demo. Katalogen og detaljvisningen leser fra databasen. +- **Appen lagrer ikke leie ennå.** Leieforespørselen er demo. Katalogen og + detaljvisningen leser fra databasen, og utleieskjemaet lagrer nye annonser + der. Det finnes ingen innlogging, så alle kan legge ut annonser. ### Data @@ -152,16 +151,22 @@ Frontend bruker [Apollo Client](https://www.apollographql.com/docs/react/) for - **Videre arbeid:** Apollo er laget for GraphQL og håndterer mutasjoner og oppdatering av cachen når brukere lagrer data. -| Fil | Innhold | -| ------------------------------- | -------------------------------------------- | -| `src/graphql/client.ts` | Apollo-klienten og adressen til API-et | -| `src/graphql/listingQueries.ts` | Spørringene for katalogen og detaljvisningen | -| `src/test/listingMocks.ts` | Mockede GraphQL-svar til testene | +| Fil | Innhold | +| --------------------------------- | -------------------------------------------- | +| `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/test/listingMocks.ts` | Mockede GraphQL-svar til testene | Katalogen henter bare feltene annonsekortet viser, og 24 annonser per side. Én side er omtrent 4 kB. Resten av opplysningene hentes når brukeren åpner en annonse. Gjeldende side ligger i URL-en som `?side=2`. +**Nye annonser:** Utleieskjemaet sender annonsen til `createListing` når +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. + **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 @@ -424,7 +429,7 @@ Rutene er definert i `src/App.tsx` med React Router. | ---------------------- | ------------------- | ------------------------------------------- | | `/` | `CatalogPage` | Annonser fra GraphQL, én side om gangen | | `/annonser/:listingId` | `ListingDetailPage` | Alle opplysninger og leieforespørsel (demo) | -| `/lei-ut` | `RentOutPage` | Skjema for å legge ut verktøy (demo) | +| `/lei-ut` | `RentOutPage` | Skjema som lagrer nye annonser | | Alle andre | `NotFoundPage` | «Fant ikke siden» | Ny side: lag en mappe i `src/pages/`, legg til en `Route` i `App.tsx` og kall diff --git a/docs/design.md b/docs/design.md index 5adcb9b..9cd5e54 100644 --- a/docs/design.md +++ b/docs/design.md @@ -275,9 +275,14 @@ nettlesere viser ugyldig input som en tom verdi. Med tekstfelt kan valideringen gi en presis feilmelding. Feil vises ved feltet først etter et forsøk på innsending. Da flyttes også -fokus til første ugyldige felt. Innsendingen er en demo. Knappen heter «Legg -ut verktøy (demo)», og bekreftelsen i `role="status"` sier at annonsen ikke er -lagret og at katalogen ikke er endret. +fokus til første ugyldige felt. + +Et gyldig skjema lagres med `createListing`. Mens annonsen lagres, er knappen +deaktivert og viser «Legger ut …», slik at samme annonse ikke sendes to +ganger. Etter lagring tømmes skjemaet, og en bekreftelse i `role="status"` +lenker til den nye annonsen. Avviser serveren annonsen, eller svarer den ikke, +vises en feil med rød kant i `role="alert"`. Det brukeren har fylt ut, beholdes +da, så hen kan rette opp eller prøve igjen. ### Layout diff --git a/docs/ki-bruk.md b/docs/ki-bruk.md index b6e69f7..a9f6bf2 100644 --- a/docs/ki-bruk.md +++ b/docs/ki-bruk.md @@ -45,3 +45,4 @@ ser hva vi bør være oppmerksomme på. | #39 | Søkte etter åpne datasett om verktøy på nettet (Wikidata, Open Products Facts, Geonorge) og foreslo Wikidata for verktøytyper og merker. Veiledet oppsett av lokal PostgreSQL-database. Skrev skript som henter verktøymodeller fra Wikidata (`import:tools`), migrasjon for `created_at`, generator for varierte annonser med tester og `seed:large` som setter inn datasettet | Wikidata-kategoriene ga mye støy, så verktøytypene velges med faste ID-er. Stikkprøver av genererte annonser viste tekster, tilbehør og merker som ikke passet verktøyet (for eksempel «båndsliper» som «passer til å skru opp hyller»), og dette ble rettet før commit. Migrasjon testet med up/down mot lokal database. | | #42 | Laget paginering i `listings` med validering, `createdAt` i skjemaet, indeks for sorteringen og API-tester, og målte spørringene med `EXPLAIN ANALYZE` | Kjørt mot ekte PostgreSQL 18 lokalt med 10 010 og 50 010 annonser. En test for rekkefølge ved like tidspunkt bestod også med feil sortering, fordi seed-annonsene har ulike tidspunkt. Testen ble skrevet om med egne testannonser og kontrollert ved å innføre feilen med vilje. Målingene viste at sider langt bak blir tregere med `OFFSET`, og det er dokumentert | | #43 | Hjalp med oppsett av Apollo Client, katalog med sider, detaljvisning mot GraphQL og mockede GraphQL-svar i testene | Gruppen testet hele flyten lokalt mot PostgreSQL med 10 010 annonser, også feiltilstanden med backenden stoppet, og koden gjennomgås i PR før merge | +| #44 | Koblet utleieskjemaet til `createListing` med Apollo: mutasjon, omgjøring av skjemaverdier, oppdatering av katalogcachen, bekreftelse med lenke og feilmeldinger. Skrev komponenttester med mockede GraphQL-svar | Gruppemedlemmet testet selv lokalt mot PostgreSQL: annonsen ble lagret, vist først i katalogen uten ny sidelasting og åpnet i detaljvisningen. Med backenden stoppet ble feilen vist og feltene beholdt. Typekontrollen fant en feil i en av testene, som ble rettet før commit | diff --git a/docs/testing.md b/docs/testing.md index d4c80f5..bc4aaef 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -68,12 +68,13 @@ testdatabase-URL kjører fortsatt. | `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | | `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | | `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | -| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | +| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3», skjemaverdier gjort om til input for `createListing` | | `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | | `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | | `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | | `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | -| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | +| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning. Innsending med mockede GraphQL-svar: bekreftelse med lenke, tømt skjema, deaktivert knapp under lagring, feil fra serveren og nettverksfeil der feltene beholdes | +| `graphql/listingMutations.test.ts` | Feilmelding ved `BAD_USER_INPUT`, andre feil gir ingen melding, katalogsider fjernes fra cachen etter lagring | | `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | | `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | | `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | diff --git a/src/components/ListingForm/ListingForm.module.css b/src/components/ListingForm/ListingForm.module.css index 37dd3b4..0102f51 100644 --- a/src/components/ListingForm/ListingForm.module.css +++ b/src/components/ListingForm/ListingForm.module.css @@ -19,8 +19,8 @@ .previewLabel, .previewEmpty, -.demoNote, -.status { +.status p, +.error p { margin: 0; } @@ -28,8 +28,7 @@ font-weight: var(--font-weight-bold); } -.previewEmpty, -.demoNote { +.previewEmpty { color: var(--color-text-muted); font-size: var(--font-size-sm); } @@ -41,3 +40,11 @@ border-radius: var(--radius-md); background: var(--color-surface); } + +/* Feilen får rød kant i tillegg til teksten, som skjemafeltene. */ +.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/ListingForm/ListingForm.test.tsx b/src/components/ListingForm/ListingForm.test.tsx index 1c42a23..bb9f2ee 100644 --- a/src/components/ListingForm/ListingForm.test.tsx +++ b/src/components/ListingForm/ListingForm.test.tsx @@ -1,15 +1,29 @@ -import { render, screen } from '@testing-library/react'; +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render as renderElement, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router'; import { describe, expect, it } from 'vitest'; +import { + createListingMock, + createListingRejectedMock, + createListingRequest, + networkErrorMock, +} from '../../test/listingMocks.ts'; import ListingForm from './ListingForm.tsx'; -function submit() { - return userEvent.click( - screen.getByRole('button', { name: 'Legg ut verktøy (demo)' }), +/** Skjemaet bruker Apollo for å lagre og en router for lenken til annonsen. */ +function render(element: ReactElement, mocks: MockLink.MockedResponse[] = []) { + return renderElement( + + {element} + , ); } -async function fillValidForm(dailyPrice: string) { +/** Fyller ut feltene slik at de gir validCreateListingInput i listingMocks. */ +async function fillValidForm() { await userEvent.type( screen.getByRole('textbox', { name: 'Tittel' }), 'Stikksag', @@ -25,7 +39,13 @@ async function fillValidForm(dailyPrice: string) { await userEvent.type(screen.getByRole('textbox', { name: 'Område' }), 'Lade'); await userEvent.type( screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), - dailyPrice, + '80', + ); +} + +function submit() { + return userEvent.click( + screen.getByRole('button', { name: 'Legg ut verktøy' }), ); } @@ -74,21 +94,85 @@ describe('ListingForm', () => { ); expect(screen.getByText('150 kr/døgn')).toBeInTheDocument(); }); +}); - it('sier tydelig at annonsen ikke er lagret', async () => { - render(); - await fillValidForm('80'); +describe('ListingForm innsending', () => { + it('lagrer annonsen, viser bekreftelse med lenke og tømmer skjemaet', async () => { + render(, [createListingMock('listing-ny')]); + await fillValidForm(); await submit(); - expect(screen.getByRole('status')).toHaveTextContent( - /Annonsen er ikke lagret/, + + const status = await screen.findByText(/er lagt ut/); + expect(status).toHaveTextContent('Annonsen «Stikksag» er lagt ut'); + expect(screen.getByRole('status')).toContainElement(status); + expect(screen.getByRole('link', { name: 'Se annonsen' })).toHaveAttribute( + 'href', + '/annonser/listing-ny', ); + expect(screen.getByRole('textbox', { name: 'Tittel' })).toHaveValue(''); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('deaktiverer knappen mens annonsen lagres', async () => { + render(, [createListingMock()]); + await fillValidForm(); + await submit(); + + expect(screen.getByRole('button', { name: 'Legger ut …' })).toBeDisabled(); + await screen.findByText(/er lagt ut/); + expect( + screen.getByRole('button', { name: 'Legg ut verktøy' }), + ).toBeEnabled(); }); - it('fjerner demomeldingen når brukeren endrer et felt', async () => { + it('sender ikke annonsen når klientvalideringen feiler', async () => { + // Uten mocks ville en innsending gitt feil, så ingen feil betyr at + // ingenting ble sendt. render(); - await fillValidForm('80'); await submit(); - await userEvent.type(screen.getByRole('textbox', { name: 'Område' }), 'n'); + + expect(screen.getByRole('textbox', { name: 'Tittel' })).toHaveAttribute( + 'aria-invalid', + 'true', + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('viser feilen når serveren avviser annonsen og beholder feltene', async () => { + render(, [ + createListingRejectedMock('Tittelen kan ha maks 80 tegn.'), + ]); + await fillValidForm(); + await submit(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Annonsen ble ikke lagret.'); + expect(alert).toHaveTextContent('Tittelen kan ha maks 80 tegn.'); + expect(screen.getByRole('textbox', { name: 'Tittel' })).toHaveValue( + 'Stikksag', + ); + }); + + it('viser nettverksfeil og beholder det brukeren har fylt ut', async () => { + render(, [networkErrorMock(createListingRequest())]); + await fillValidForm(); + await submit(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Vi fikk ikke kontakt med serveren'); + expect(screen.getByRole('textbox', { name: 'Område' })).toHaveValue('Lade'); + expect( + screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), + ).toHaveValue('80'); + }); + + it('fjerner bekreftelsen når brukeren begynner på en ny annonse', async () => { + render(, [createListingMock()]); + await fillValidForm(); + await submit(); + await screen.findByText(/er lagt ut/); + + await userEvent.type(screen.getByRole('textbox', { name: 'Tittel' }), 'H'); expect(screen.getByRole('status')).toBeEmptyDOMElement(); }); }); diff --git a/src/components/ListingForm/ListingForm.tsx b/src/components/ListingForm/ListingForm.tsx index 9772fb2..5d4c27d 100644 --- a/src/components/ListingForm/ListingForm.tsx +++ b/src/components/ListingForm/ListingForm.tsx @@ -1,10 +1,18 @@ +import { useMutation } from '@apollo/client/react'; import { useEffect, useRef, useState, type SubmitEvent } from 'react'; +import { Link } from 'react-router'; +import { + CREATE_LISTING_MUTATION, + removeCachedCatalogPages, + serverValidationMessage, +} from '../../graphql/listingMutations.ts'; import { categoryLabels } from '../../utils/listingFormat.ts'; import { DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH, emptyListingForm, parseDailyPrice, + toCreateListingInput, validateListingForm, type ListingFormValues, } from '../../utils/listingForm.ts'; @@ -19,19 +27,30 @@ const categoryOptions = Object.entries(categoryLabels).map( ([value, label]) => ({ value, label }), ); +/** Resultatet av siste innsending, som vises under knappen. */ +type SubmitResult = + | { kind: 'created'; id: string; title: string } + | { kind: 'rejected'; message: string } + | { kind: 'networkError' }; + /** - * Skjema for å legge ut et verktøy for utleie eller gratis utlån. Før backend - * finnes er innsendingen en demo: annonsen lagres ikke og vises ikke i - * katalogen, og det sier både knappen og bekreftelsen tydelig. + * Skjema for å legge ut et verktøy for utleie eller gratis utlån. Annonsen + * lagres med createListing, og katalogen hentes på nytt neste gang den vises, + * slik at den nye annonsen ligger øverst. */ function ListingForm() { const [values, setValues] = useState(emptyListingForm); // Feil vises først etter et forsøk på innsending, så brukeren ikke får // feilmeldinger mens hen fyller ut feltene for første gang. const [submitCount, setSubmitCount] = useState(0); - const [isDemoSaved, setIsDemoSaved] = useState(false); + const [result, setResult] = useState(null); const formRef = useRef(null); + const [createListing, { loading: isSaving }] = useMutation( + CREATE_LISTING_MUTATION, + { update: (cache) => removeCachedCatalogPages(cache) }, + ); + const errors = validateListingForm(values); const isValid = Object.keys(errors).length === 0; const visibleErrors = submitCount > 0 ? errors : {}; @@ -50,13 +69,40 @@ function ListingForm() { function updateField(field: keyof ListingFormValues, value: string) { setValues((current) => ({ ...current, [field]: value })); - setIsDemoSaved(false); + // En gammel bekreftelse eller feil gjelder ikke lenger når skjemaet endres. + setResult(null); } - function handleSubmit(event: SubmitEvent) { + async function handleSubmit(event: SubmitEvent) { event.preventDefault(); + // Knappen er deaktivert mens annonsen lagres, men Enter i et felt kan + // fortsatt sende skjemaet. Da skal ikke samme annonse sendes to ganger. + if (isSaving) { + return; + } setSubmitCount((count) => count + 1); - setIsDemoSaved(isValid); + setResult(null); + if (!isValid) { + return; + } + + try { + const { data } = await createListing({ + variables: { input: toCreateListingInput(values) }, + }); + if (!data) { + throw new Error('Serveren svarte uten data.'); + } + setResult({ kind: 'created', ...data.createListing }); + setValues(emptyListingForm); + setSubmitCount(0); + } catch (error) { + // Feltene beholdes, så brukeren kan rette opp eller prøve igjen. + const message = serverValidationMessage(error); + setResult( + message ? { kind: 'rejected', message } : { kind: 'networkError' }, + ); + } } return ( @@ -120,18 +166,33 @@ function ListingForm() { )} -

- Dette er en demo. Annonsen blir ikke lagret og vises ikke i katalogen. -

- +
{/* Alltid i DOM-en, slik at skjermlesere leser opp ny tekst i den. */} -

- {isDemoSaved && - 'Demo: Annonsen er ikke lagret, og katalogen er ikke endret. Ekte annonser kan legges ut når backend er på plass.'} -

+
+ {result?.kind === 'created' && ( +

+ Annonsen «{result.title}» er lagt ut og vises først i katalogen.{' '} + Se annonsen +

+ )} +
+ + {result && result.kind !== 'created' && ( + // role="alert" leses opp med en gang, fordi annonsen ikke ble lagret. +
+

+ Annonsen 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.'} +

+
+ )} ); } diff --git a/src/graphql/listingMutations.test.ts b/src/graphql/listingMutations.test.ts new file mode 100644 index 0000000..a723fe3 --- /dev/null +++ b/src/graphql/listingMutations.test.ts @@ -0,0 +1,66 @@ +import { CombinedGraphQLErrors, InMemoryCache } from '@apollo/client'; +import { describe, expect, it } from 'vitest'; +import { LISTINGS_PAGE_QUERY } from './listingQueries.ts'; +import { + removeCachedCatalogPages, + serverValidationMessage, +} from './listingMutations.ts'; + +describe('serverValidationMessage', () => { + it('gir meldingen når serveren avviser annonsen', () => { + const error = new CombinedGraphQLErrors({ + data: null, + errors: [ + { + message: 'Tittelen kan ha maks 80 tegn.', + extensions: { code: 'BAD_USER_INPUT' }, + }, + ], + }); + + expect(serverValidationMessage(error)).toBe( + 'Tittelen kan ha maks 80 tegn.', + ); + }); + + it('gir null for andre GraphQL-feil og for nettverksfeil', () => { + const serverError = new CombinedGraphQLErrors({ + data: null, + errors: [ + { + message: 'Noe gikk galt.', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }, + ], + }); + + expect(serverValidationMessage(serverError)).toBeNull(); + expect( + serverValidationMessage(new TypeError('Failed to fetch')), + ).toBeNull(); + }); +}); + +describe('removeCachedCatalogPages', () => { + it('fjerner lagrede katalogsider, slik at de hentes på nytt', () => { + const cache = new InMemoryCache(); + const variables = { limit: 24, offset: 0 }; + cache.writeQuery({ + query: LISTINGS_PAGE_QUERY, + variables, + data: { + listings: { + items: [], + totalCount: 0, + hasNextPage: false, + }, + }, + }); + + removeCachedCatalogPages(cache); + + expect( + cache.readQuery({ query: LISTINGS_PAGE_QUERY, variables }), + ).toBeNull(); + }); +}); diff --git a/src/graphql/listingMutations.ts b/src/graphql/listingMutations.ts new file mode 100644 index 0000000..ee38d4d --- /dev/null +++ b/src/graphql/listingMutations.ts @@ -0,0 +1,66 @@ +import { + CombinedGraphQLErrors, + gql, + type ApolloCache, + type TypedDocumentNode, +} from '@apollo/client'; +import type { ToolCategory } from '../types/listing.ts'; + +/** Feltene backenden tar imot når en annonse opprettes. */ +export interface CreateListingInput { + title: string; + category: ToolCategory; + description: string; + area: string; + /** Hele kroner per døgn. 0 betyr gratis utlån. */ + dailyPrice: number; +} + +export interface CreateListingData { + createListing: { id: string; title: string }; +} + +export interface CreateListingVariables { + input: CreateListingInput; +} + +/** + * Oppretter en annonse. Svaret trenger bare ID og tittel til bekreftelsen og + * lenken til den nye annonsen. Resten hentes når annonsen åpnes. + */ +export const CREATE_LISTING_MUTATION: TypedDocumentNode< + CreateListingData, + CreateListingVariables +> = gql` + mutation CreateListing($input: CreateListingInput!) { + createListing(input: $input) { + id + title + } + } +`; + +/** + * Fjerner alle hentede katalogsider fra cachen. En ny annonse havner først i + * katalogen og forskyver alle sidene, så de lagrede sidene er utdaterte. Neste + * gang katalogen vises, hentes siden på nytt fra serveren, uten at brukeren + * må laste siden på nytt. + */ +export function removeCachedCatalogPages(cache: ApolloCache) { + cache.evict({ id: 'ROOT_QUERY', fieldName: 'listings' }); + cache.gc(); +} + +/** + * Melding fra serveren når den avviser annonsen (BAD_USER_INPUT), for + * eksempel for lang tittel. Returnerer null for andre feil, som nettverksfeil. + */ +export function serverValidationMessage(error: unknown): string | null { + if (!CombinedGraphQLErrors.is(error)) { + return null; + } + const validationError = error.errors.find( + (graphQLError) => graphQLError.extensions?.code === 'BAD_USER_INPUT', + ); + return validationError?.message ?? null; +} diff --git a/src/test/listingMocks.ts b/src/test/listingMocks.ts index 7d02586..a2a8e98 100644 --- a/src/test/listingMocks.ts +++ b/src/test/listingMocks.ts @@ -1,5 +1,9 @@ import type { MockLink } from '@apollo/client/testing'; import { mockListings } from '../data/mockListings.ts'; +import { + CREATE_LISTING_MUTATION, + type CreateListingInput, +} from '../graphql/listingMutations.ts'; import { CATALOG_PAGE_SIZE, LISTINGS_PAGE_QUERY, @@ -78,3 +82,50 @@ export function networkErrorMock( ): MockLink.MockedResponse { return { request, error: new Error('Failed to fetch') }; } + +/** Gyldig annonse fra utleieskjemaet, slik den sendes til createListing. */ +export const validCreateListingInput: CreateListingInput = { + title: 'Stikksag', + category: 'powerTools', + description: 'Stikksag for tre.', + area: 'Lade', + dailyPrice: 80, +}; + +export function createListingRequest( + input: CreateListingInput = validCreateListingInput, +): MockLink.MockedResponse['request'] { + return { query: CREATE_LISTING_MUTATION, variables: { input } }; +} + +/** + * Vellykket createListing. delay holder svaret igjen litt, slik at testene + * kan se at knappen er deaktivert mens annonsen lagres. + */ +export function createListingMock( + id = 'listing-ny', + input: CreateListingInput = validCreateListingInput, +): MockLink.MockedResponse { + return { + request: createListingRequest(input), + delay: 20, + result: { + data: { + createListing: { __typename: 'Listing', id, title: input.title }, + }, + }, + }; +} + +/** Serveren avviser annonsen, som når valideringen i backenden feiler. */ +export function createListingRejectedMock( + message: string, + input: CreateListingInput = validCreateListingInput, +): MockLink.MockedResponse { + return { + request: createListingRequest(input), + result: { + errors: [{ message, extensions: { code: 'BAD_USER_INPUT' } }], + }, + }; +} diff --git a/src/utils/listingForm.test.ts b/src/utils/listingForm.test.ts index 6590a15..6001268 100644 --- a/src/utils/listingForm.test.ts +++ b/src/utils/listingForm.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { emptyListingForm, parseDailyPrice, + toCreateListingInput, validateListingForm, type ListingFormValues, } from './listingForm.ts'; @@ -67,3 +68,26 @@ describe('validateListingForm', () => { expect(errors.dailyPrice).toBeDefined(); }); }); + +describe('toCreateListingInput', () => { + it('fjerner mellomrom og gjør prisen om til et tall', () => { + expect( + toCreateListingInput({ + ...validForm, + title: ' Stikksag ', + area: ' Lade ', + dailyPrice: ' 0 ', + }), + ).toEqual({ + title: 'Stikksag', + category: 'powerTools', + description: 'Stikksag for tre og laminat.', + area: 'Lade', + dailyPrice: 0, + }); + }); + + it('nekter å lage input fra et ugyldig skjema', () => { + expect(() => toCreateListingInput(emptyListingForm)).toThrow(); + }); +}); diff --git a/src/utils/listingForm.ts b/src/utils/listingForm.ts index e4b675f..d56a06d 100644 --- a/src/utils/listingForm.ts +++ b/src/utils/listingForm.ts @@ -1,8 +1,12 @@ /** * Validering av skjemaet for å legge ut verktøy. Verdiene er strenger slik de - * kommer fra skjemafeltene. Senere skal serveren gjøre den samme kontrollen. + * kommer fra skjemafeltene. Serveren gjør den samme kontrollen når annonsen + * lagres (backend/src/validation/createListingInput.ts). */ +import type { CreateListingInput } from '../graphql/listingMutations.ts'; +import type { ToolCategory } from '../types/listing.ts'; + export interface ListingFormValues { title: string; /** Tom streng til brukeren har valgt en kategori. */ @@ -80,3 +84,23 @@ export function validateListingForm( return errors; } + +/** + * Gjør skjemaverdiene om til input for createListing. Skal bare kalles når + * validateListingForm ikke har funnet feil, slik at kategori og pris er gyldige. + */ +export function toCreateListingInput( + values: ListingFormValues, +): CreateListingInput { + const dailyPrice = parseDailyPrice(values.dailyPrice); + if (dailyPrice === null || !values.category) { + throw new Error('Skjemaet må være gyldig før det sendes til serveren.'); + } + return { + title: values.title.trim(), + category: values.category as ToolCategory, + description: values.description.trim(), + area: values.area.trim(), + dailyPrice, + }; +}