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
26 changes: 22 additions & 4 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(
`
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions client/src/components/Pagination.css
Original file line number Diff line number Diff line change
@@ -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;
}
57 changes: 57 additions & 0 deletions client/src/components/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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:
* - <nav> with aria-label, so screen reader users can jump straight to it
* - real <button>s, so it works with keyboard (Tab + Enter/Space) out of the box
* - disabled buttons at the first/last page instead of hiding them,
* so the layout doesn't jump and it's clear where you are
*/
export default function Pagination({
page,
totalPages,
onPageChange,
disabled = false,
}: Props) {
// One page or fewer: nothing to navigate between
if (totalPages <= 1) return null;

return (
<nav className="pagination" aria-label="Sidenavigering">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={disabled || page <= 1}
>
<span aria-hidden="true">← </span>Forrige
</button>

<p className="pagination-status">
Side {page} av {totalPages}
</p>

<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={disabled || page >= totalPages}
>
Neste<span aria-hidden="true"> →</span>
</button>
</nav>
);
}
53 changes: 45 additions & 8 deletions client/src/components/WineCatalog.tsx
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -35,20 +39,53 @@ type WinesData = {
};

export default function WineCatalog() {
const { data, loading, error } = useQuery<WinesData>(WINES_QUERY, {
variables: { offset: 0, limit: 20 },
});
const [page, setPage] = useState(1);
const offset = (page - 1) * PAGE_SIZE;

const { data, previousData, loading, error } = useQuery<WinesData>(
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 <p role="status">Laster viner …</p>;
if (error) {
return <p role="alert">Kunne ikke hente viner. Prøv igjen senere.</p>;
}
if (!shown) return <p role="status">Laster viner …</p>;

const totalCount = shown.wines.totalCount;
const totalPages = Math.ceil(totalCount / PAGE_SIZE);

return (
<WineList
wines={data?.wines.items ?? []}
totalCount={data?.wines.totalCount ?? 0}
/>
// aria-busy tells screen readers the content is being updated
<div aria-busy={loading}>
<WineList
wines={shown.wines.items}
totalCount={totalCount}
offset={offset}
/>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={changePage}
disabled={loading}
/>
</div>
);
}
6 changes: 4 additions & 2 deletions client/src/components/WineList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -21,7 +23,7 @@ type Props = {
* - <article> per wine, labelled by the wine's name
* - <dl>/<dt>/<dd> 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 <p role="status">Ingen viner funnet.</p>;
}
Expand All @@ -30,7 +32,7 @@ export default function WineList({ wines, totalCount }: Props) {
<section aria-labelledby="catalog-heading">
<h2 id="catalog-heading">Vinkatalog</h2>
<p className="wine-count">
Viser {wines.length} av {totalCount} viner
Viser {offset + 1}-{offset + wines.length} av {totalCount} viner
</p>

{/*
Expand Down