From 9e95754eb40937c0dd6c769dec41dc99a6320ca2 Mon Sep 17 00:00:00 2001 From: adelethorberg-sudo Date: Sun, 11 Oct 2026 14:26:55 +0200 Subject: [PATCH] Lagt til paginering i vinkatalogen --- backend/src/index.ts | 26 ++++++++++-- client/src/components/Pagination.css | 43 ++++++++++++++++++++ client/src/components/Pagination.tsx | 57 +++++++++++++++++++++++++++ client/src/components/WineCatalog.tsx | 53 +++++++++++++++++++++---- client/src/components/WineList.tsx | 6 ++- 5 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 client/src/components/Pagination.css create mode 100644 client/src/components/Pagination.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index dbbc058..8e06108 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,27 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; +import {GraphQLError} from "graphql"; import { pool } from "./db.js"; +const MAX_LIMIT = 100; + +function readPaging(args: { limit?: number; offset?: number }) { + const limit = args.limit ?? 20; + const offset = args.offset ?? 0; + + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new GraphQLError(`limit må være mellom 1 og ${MAX_LIMIT}`, { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (!Number.isInteger(offset) || offset < 0) { + throw new GraphQLError("offset kan ikke være negativ", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + return { limit, offset }; +} + const typeDefs = `#graphql type Wine { @@ -75,8 +95,7 @@ const resolvers = { _: unknown, args: { limit?: number; offset?: number } ) => { - const limit = args.limit ?? 20; - const offset = args.offset ?? 0; + const { limit, offset } = readPaging(args); const winesResult = await pool.query( ` @@ -191,8 +210,7 @@ const resolvers = { _: unknown, args: { category: string; limit?: number; offset?: number } ) => { - const limit = args.limit ?? 20; - const offset = args.offset ?? 0; + const { limit, offset } = readPaging(args); const inCategory = ` EXISTS ( SELECT 1 diff --git a/client/src/components/Pagination.css b/client/src/components/Pagination.css new file mode 100644 index 0000000..577abe2 --- /dev/null +++ b/client/src/components/Pagination.css @@ -0,0 +1,43 @@ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin: 2rem 0 1rem; +} + +.pagination-status { + margin: 0; + color: var(--color-muted); +} + +.pagination button { + /* At least 44x44 px, so the buttons are easy to hit on touch screens (WCAG 2.5.5) */ + min-height: 2.75rem; + min-width: 2.75rem; + padding: 0.5rem 1rem; + font: inherit; + color: var(--color-surface); + background: var(--color-accent); + border: 2px solid var(--color-accent); + border-radius: 0.5rem; + cursor: pointer; +} + +.pagination button:hover:not(:disabled) { + background: var(--color-text); + border-color: var(--color-text); +} + +/* Clear focus ring for keyboard users, not shown on mouse click */ +.pagination button:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 3px; +} + +.pagination button:disabled { + color: var(--color-muted); + background: transparent; + border-color: var(--color-border); + cursor: not-allowed; +} diff --git a/client/src/components/Pagination.tsx b/client/src/components/Pagination.tsx new file mode 100644 index 0000000..cfe367e --- /dev/null +++ b/client/src/components/Pagination.tsx @@ -0,0 +1,57 @@ +import './Pagination.css'; + +type Props = { + /** Current page, starting at 1 */ + page: number; + totalPages: number; + onPageChange: (page: number) => void; + /** True while the next page is loading, so the user can't click twice */ + disabled?: boolean; +}; + +/** + * Previous/next navigation between pages of results. + * + * Display only, like WineList: it knows nothing about GraphQL, it just reports + * which page the user wants. That makes it reusable (Find wine by food, + * Favourites ...) and easy to test with Vitest. + * + * Accessibility: + * - + ); +} diff --git a/client/src/components/WineCatalog.tsx b/client/src/components/WineCatalog.tsx index 17a3fa2..fbb4d69 100644 --- a/client/src/components/WineCatalog.tsx +++ b/client/src/components/WineCatalog.tsx @@ -1,8 +1,12 @@ +import { useEffect, useRef, useState } from 'react'; import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; import WineList from './WineList'; +import Pagination from './Pagination'; import type { WineListItem } from '../types/wine'; +export const PAGE_SIZE = 20; + /** * Fetches wines from the GraphQL backend and passes them to WineList. * @@ -35,20 +39,53 @@ type WinesData = { }; export default function WineCatalog() { - const { data, loading, error } = useQuery(WINES_QUERY, { - variables: { offset: 0, limit: 20 }, - }); + const [page, setPage] = useState(1); + const offset = (page - 1) * PAGE_SIZE; + + const { data, previousData, loading, error } = useQuery( + WINES_QUERY, + { variables: { offset, limit: PAGE_SIZE } }, + ); + + const shown = data ?? previousData; + + const pageChanged = useRef(false); + useEffect(() => { + if (!pageChanged.current || !data) return; + pageChanged.current = false; + const heading = document.getElementById('catalog-heading'); + heading?.focus(); + heading?.scrollIntoView({ block: 'start' }); + }, [data]); + + function changePage(newPage: number) { + pageChanged.current = true; + setPage(newPage); + } // role="status" and role="alert" make screen readers announce the messages - if (loading) return

Laster viner …

; if (error) { return

Kunne ikke hente viner. Prøv igjen senere.

; } + if (!shown) return

Laster viner …

; + + const totalCount = shown.wines.totalCount; + const totalPages = Math.ceil(totalCount / PAGE_SIZE); return ( - + // aria-busy tells screen readers the content is being updated +
+ + +
); } diff --git a/client/src/components/WineList.tsx b/client/src/components/WineList.tsx index c558c43..076bb52 100644 --- a/client/src/components/WineList.tsx +++ b/client/src/components/WineList.tsx @@ -5,6 +5,8 @@ type Props = { wines: WineListItem[]; /** Total number of wines in the database, not just the ones shown */ totalCount: number; + /** How many wines come before this page (0 on the first page) */ + offset?: number; }; /** @@ -21,7 +23,7 @@ type Props = { * -
per wine, labelled by the wine's name * -
/
/
so each property is read with its label */ -export default function WineList({ wines, totalCount }: Props) { +export default function WineList({ wines, totalCount, offset = 0 }: Props) { if (wines.length === 0) { return

Ingen viner funnet.

; } @@ -30,7 +32,7 @@ export default function WineList({ wines, totalCount }: Props) {

Vinkatalog

- Viser {wines.length} av {totalCount} viner + Viser {offset + 1}-{offset + wines.length} av {totalCount} viner

{/*