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,
+ };
+}