Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/ki-bruk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
5 changes: 3 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 11 additions & 4 deletions src/components/ListingForm/ListingForm.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,16 @@

.previewLabel,
.previewEmpty,
.demoNote,
.status {
.status p,
.error p {
margin: 0;
}

.previewLabel {
font-weight: var(--font-weight-bold);
}

.previewEmpty,
.demoNote {
.previewEmpty {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
Expand All @@ -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);
}
112 changes: 98 additions & 14 deletions src/components/ListingForm/ListingForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MockedProvider mocks={mocks}>
<MemoryRouter>{element}</MemoryRouter>
</MockedProvider>,
);
}

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',
Expand All @@ -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' }),
);
}

Expand Down Expand Up @@ -74,21 +94,85 @@ describe('ListingForm', () => {
);
expect(screen.getByText('150 kr/døgn')).toBeInTheDocument();
});
});

it('sier tydelig at annonsen ikke er lagret', async () => {
render(<ListingForm />);
await fillValidForm('80');
describe('ListingForm innsending', () => {
it('lagrer annonsen, viser bekreftelse med lenke og tømmer skjemaet', async () => {
render(<ListingForm />, [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(<ListingForm />, [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(<ListingForm />);
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(<ListingForm />, [
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(<ListingForm />, [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(<ListingForm />, [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();
});
});
Loading